From a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56 Mon Sep 17 00:00:00 2001 From: Sergey Krylov Date: Mon, 28 Sep 2026 11:04:18 +0300 Subject: [PATCH] chore: strengthen backend eslint profile --- .ai/architecture.md | 4 +- .prettierrc | 4 - README.md | 28 + commitlint.config.cjs | 4 +- eslint-suppressions.json | 206 ++++ eslint.config.mjs | 110 +- package-lock.json | 969 ++++++++++++++---- package.json | 30 +- scripts/check-release.cjs | 20 +- scripts/generate-openapi.cjs | 1 + src/app.module.ts | 3 +- src/common/decimal.ts | 16 +- .../api-safe-error-response.decorator.ts | 11 +- src/common/dto-validation.spec.ts | 4 +- .../filters/http-exception.filter.spec.ts | 1 + src/common/filters/http-exception.filter.ts | 29 +- src/common/logging/logging.module.ts | 1 + src/common/logging/request-context.service.ts | 6 +- .../request-logging.middleware.spec.ts | 5 +- .../logging/request-logging.middleware.ts | 32 +- .../logging/structured-logger.service.ts | 32 +- src/common/money.dto.ts | 1 + src/config/env.validation.ts | 14 +- src/integrations/moex/moex.client.spec.ts | 15 +- src/integrations/moex/moex.client.ts | 52 +- src/integrations/moex/moex.module.ts | 1 + .../ncr/ncr-ratings.client.spec.ts | 13 + src/integrations/ncr/ncr-ratings.client.ts | 28 +- src/integrations/ncr/ncr.module.ts | 1 + src/integrations/tbank/decimal.spec.ts | 15 + src/integrations/tbank/decimal.ts | 2 +- .../tbank/instrument-metadata.repository.ts | 64 +- .../tbank/instrument-metadata.service.ts | 53 +- .../tbank/tbank-access.service.ts | 40 +- .../tbank/tbank-account.policy.ts | 4 +- src/integrations/tbank/tbank-market.types.ts | 46 +- .../tbank/tbank-membership.service.ts | 1 + .../tbank/tbank-source.policy.spec.ts | 1 + src/integrations/tbank/tbank-source.policy.ts | 1 + src/integrations/tbank/tbank-token.service.ts | 4 +- src/integrations/tbank/tbank.api.ts | 22 +- src/integrations/tbank/tbank.client.spec.ts | 34 +- src/integrations/tbank/tbank.client.ts | 133 +-- src/integrations/tbank/tbank.crypto.ts | 3 +- src/integrations/tbank/tbank.module.ts | 3 +- src/main.ts | 8 +- .../auth/application/auth.service.spec.ts | 2 +- src/modules/auth/application/auth.service.ts | 33 +- src/modules/auth/application/auth.types.ts | 10 +- src/modules/auth/auth.module.ts | 5 +- .../auth/infrastructure/jwt.strategy.ts | 4 +- .../auth/presentation/auth.controller.ts | 9 +- src/modules/auth/presentation/auth.dto.ts | 4 +- .../auth/presentation/jwt-auth.guard.ts | 4 +- .../bank-cards/application/bank-card-input.ts | 25 +- .../application/bank-cards.service.ts | 55 +- .../application/bank-operations.service.ts | 124 ++- .../application/expense-categories.service.ts | 12 +- .../application/get-bank-summary.use-case.ts | 6 +- .../banking/bank-cards/bank-cards.module.ts | 5 +- .../domain/bank-card.calculations.ts | 39 +- .../bank-cards/domain/rub-money.spec.ts | 3 +- .../banking/bank-cards/domain/rub-money.ts | 15 +- .../infrastructure/bank-cards.repository.ts | 20 +- .../bank-cards/presentation/bank-card.dto.ts | 70 +- .../presentation/bank-cards.controller.ts | 26 +- .../brokerage-account.dto.ts | 10 +- .../brokerage-accounts.controller.ts | 8 +- .../brokerage-accounts.module.ts | 3 +- .../brokerage-accounts.service.spec.ts | 2 +- .../brokerage-accounts.service.ts | 39 +- .../income/income-analytics.controller.ts | 8 +- .../income/income-analytics.dto.ts | 17 +- .../income/income-analytics.service.spec.ts | 3 +- .../income/income-analytics.service.ts | 46 +- .../investments/income/income-period.spec.ts | 4 +- .../investments/income/income-period.ts | 61 +- .../investments/income/income.module.ts | 1 + .../instruments/application/candle.service.ts | 144 +-- .../application/catalog-discovery.service.ts | 138 +-- .../application/catalog-enrichment.service.ts | 285 +++--- .../application/catalog-selection.ts | 20 +- .../application/instrument-market.ts | 15 +- .../application/instrument-position.ts | 33 +- .../bonds/application/bond-catalog.service.ts | 32 +- .../bonds/application/bond-income.service.ts | 84 +- .../bonds/application/bond-market.service.ts | 47 +- .../bonds/application/bond.spec.ts | 11 +- .../bonds/infrastructure/bond-money.mapper.ts | 8 +- .../bonds/presentation/bond.controller.ts | 20 +- .../bonds/presentation/bond.dto.ts | 130 ++- .../domain/candle-coverage.spec.ts | 2 +- .../instruments/domain/candle-coverage.ts | 20 +- .../infrastructure/candle.repository.ts | 74 +- .../catalog-discovery.repository.ts | 117 ++- .../instruments/instrument-income.api.ts | 2 +- .../instruments/instrument-sources.module.ts | 5 +- .../instruments/instruments.api.ts | 6 +- .../instruments/instruments.module.ts | 7 +- .../application/get-stock-company.use-case.ts | 37 +- .../application/stock-catalog.service.ts | 34 +- .../application/stock-income.service.ts | 63 +- .../application/stock-market.service.ts | 53 +- .../stocks/application/stock.spec.ts | 2 +- .../stocks/presentation/stock.controller.ts | 20 +- .../stocks/presentation/stock.dto.ts | 135 ++- .../investments/investment-calendar.ts | 3 +- .../investments/investment-resource.dto.ts | 27 +- .../investments/investment-resource.mapper.ts | 54 +- .../investments/investment-sync-plan.spec.ts | 4 +- .../investments/investment-sync-plan.ts | 195 ++-- .../brokerage-operations-sync.service.ts | 63 +- .../brokerage-operations.service.ts | 274 +++-- .../operations/domain/operations-sync-plan.ts | 55 +- .../brokerage-operation.mapper.ts | 32 +- .../brokerage-operations.repository.ts | 256 ++--- .../investments/operations/operations.api.ts | 6 +- .../operations/operations.module.ts | 5 +- .../presentation/brokerage-operation.dto.ts | 25 +- .../brokerage-operations.controller.ts | 12 +- .../portfolio/application/forecast.service.ts | 74 +- .../application/portfolio.service.spec.ts | 7 +- .../application/portfolio.service.ts | 174 ++-- .../domain/portfolio-structure.spec.ts | 4 +- .../portfolio/domain/portfolio-structure.ts | 26 +- .../portfolio/domain/portfolio-totals.ts | 3 +- .../infrastructure/forecast.repository.ts | 38 +- .../infrastructure/portfolio.repository.ts | 22 +- .../infrastructure/tbank-portfolio.mapper.ts | 4 +- .../investments/portfolio/portfolio.api.ts | 6 +- .../investments/portfolio/portfolio.module.ts | 11 +- .../presentation/portfolio.controller.ts | 10 +- .../portfolio/presentation/portfolio.dto.ts | 24 +- .../tbank-connection.controller.ts | 4 +- .../tbank-connection/tbank-connection.dto.ts | 2 +- .../tbank-connection.module.ts | 1 + .../tbank-connection.service.ts | 3 +- .../tbank-sync/tbank-screen.spec.ts | 3 +- .../investments/tbank-sync/tbank-screen.ts | 69 +- .../tbank-sync/tbank-sync.controller.ts | 14 +- .../investments/tbank-sync/tbank-sync.dto.ts | 27 +- .../tbank-sync/tbank-sync.module.ts | 13 +- .../tbank-sync/tbank-sync.repository.ts | 21 +- .../tbank-sync/tbank-sync.service.ts | 255 ++--- .../upcoming-events.controller.ts | 6 +- .../upcoming-events/upcoming-events.dto.ts | 20 +- .../upcoming-events/upcoming-events.module.ts | 5 +- .../upcoming-events.service.spec.ts | 2 +- .../upcoming-events.service.ts | 74 +- src/prisma/prisma.module.ts | 1 + src/prisma/prisma.service.ts | 4 +- test/all-accounts.e2e-spec.ts | 21 +- test/app.e2e-spec.ts | 5 +- test/bank-cards-durability.cjs | 276 +++-- test/bank-cards.e2e-spec.ts | 52 +- test/bonds.e2e-spec.ts | 6 +- test/container-smoke.cjs | 12 +- test/e2e-env.cjs | 2 +- test/eslint-config.cjs | 47 + test/fixtures/tbank-sync-crash.cjs | 13 +- test/openapi-contract.e2e-spec.ts | 7 +- test/portfolio-structure.e2e-spec.ts | 13 +- test/sqlite-regression.cjs | 22 +- test/sqlite.cjs | 17 +- test/stocks.e2e-spec.ts | 6 +- test/tbank-candles-persistence.e2e-spec.ts | 35 +- test/tbank-catalog-discovery.e2e-spec.ts | 14 +- test/tbank-catalog-local.e2e-spec.ts | 77 +- test/tbank-connection.e2e-spec.ts | 18 +- test/tbank-income-local.e2e-spec.ts | 15 +- test/tbank-ledger-local.e2e-spec.ts | 35 +- test/tbank-local-reads.e2e-spec.ts | 26 +- test/tbank-metadata-persistence.e2e-spec.ts | 29 +- test/tbank-migration.e2e-spec.ts | 15 +- test/tbank-operations-persistence.e2e-spec.ts | 6 +- test/tbank-operations-sync.e2e-spec.ts | 36 +- test/tbank-read-baseline.e2e-spec.ts | 87 +- test/tbank-screen-sync.e2e-spec.ts | 34 +- test/upcoming-events.e2e-spec.ts | 45 +- 179 files changed, 4485 insertions(+), 2716 deletions(-) delete mode 100644 .prettierrc create mode 100644 eslint-suppressions.json create mode 100644 test/eslint-config.cjs diff --git a/.ai/architecture.md b/.ai/architecture.md index 770f008..892117b 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -235,8 +235,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе Общий выбор symbolic values и числовых policies — [Domain values](conventions/engineering.md#domain-values). Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай -`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает -`any` нормой domain/application model. `declare` допустим для заполняемых извне +`unknown` + runtime narrowing; ESLint запрещает explicit `any` +в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне DTO fields, не для сокрытия неинициализированного owned state. OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, diff --git a/.prettierrc b/.prettierrc deleted file mode 100644 index a20502b..0000000 --- a/.prettierrc +++ /dev/null @@ -1,4 +0,0 @@ -{ - "singleQuote": true, - "trailingComma": "all" -} diff --git a/README.md b/README.md index c2ec7f1..4ab2683 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,34 @@ Required checks настраиваются отдельно в Gitea: налич runtime-валидацию. В обычном коде нужны инициализация или сужение типа после проверки значения. +## ESLint + +`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts +и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные +автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела, +одинарные кавычки и точки с запятой; Prettier не используется. + +Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked, +Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp, +Security, Perfectionist recommended-natural и Stylistic customize. +NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma, +dist и coverage исключены. DI imports сохраняют runtime metadata. + +Исключения конфигурации: private backend не публикуется в npm; +TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain +unions. Автосортировка объектов, классов, модулей и декораторов отключена, +поскольку порядок может влиять на runtime. Остальные точечные исключения +объясняют false positives непосредственно в коде. + +`eslint-suppressions.json` — штатный ESLint baseline только для существующих +SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals. +Это отложенный refactor, а не исправленные нарушения. Baseline ограничен +файлами и количеством сообщений; новые файлы и превышение количества блокируют +lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве +сообщений — это остаётся предметом review. При сокращении долга обновляйте +baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его +для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены. + ## SonarQube Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` diff --git a/commitlint.config.cjs b/commitlint.config.cjs index 4aa31e2..fe88ec0 100644 --- a/commitlint.config.cjs +++ b/commitlint.config.cjs @@ -1,4 +1,4 @@ module.exports = { - extends: ["@commitlint/config-conventional"], - rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, + extends: ['@commitlint/config-conventional'], + rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] }, }; diff --git a/eslint-suppressions.json b/eslint-suppressions.json new file mode 100644 index 0000000..19e68f3 --- /dev/null +++ b/eslint-suppressions.json @@ -0,0 +1,206 @@ +{ + "src/common/decimal.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/common/logging/request-logging.middleware.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/integrations/tbank/instrument-metadata.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/integrations/tbank/tbank.client.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/application/bank-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/income/income-analytics.service.ts": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/income/income-period.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/candle.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 4 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + }, + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-discovery.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/application/catalog-enrichment.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/bonds/application/bond-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/bonds/application/bond.spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/instruments/infrastructure/candle.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + } + }, + "src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/instruments/stocks/application/stock-income.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/investment-resource.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 3 + } + }, + "src/modules/investments/investment-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 4 + } + }, + "src/modules/investments/operations/application/brokerage-operations.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 2 + }, + "sonarjs/no-nested-conditional": { + "count": 9 + } + }, + "src/modules/investments/operations/domain/operations-sync-plan.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/forecast.service.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/portfolio/application/portfolio.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + }, + "sonarjs/no-nested-conditional": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-screen.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "src/modules/investments/tbank-sync/tbank-sync.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 3 + }, + "sonarjs/no-nested-conditional": { + "count": 7 + } + }, + "src/modules/investments/upcoming-events/upcoming-events.service.ts": { + "sonarjs/cognitive-complexity": { + "count": 1 + } + }, + "test/bank-cards-durability.cjs": { + "sonarjs/no-nested-template-literals": { + "count": 1 + } + }, + "test/sqlite-regression.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/sqlite.cjs": { + "sonarjs/no-nested-conditional": { + "count": 2 + } + }, + "test/tbank-ledger-local.e2e-spec.ts": { + "sonarjs/no-nested-conditional": { + "count": 1 + }, + "sonarjs/no-nested-template-literals": { + "count": 3 + } + } +} \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index 537b840..81c0330 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -1,31 +1,71 @@ // @ts-check +import nest from '@darraghor/eslint-plugin-nestjs-typed'; import eslint from '@eslint/js'; -import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; +import stylistic from '@stylistic/eslint-plugin'; +import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript'; +import { importX } from 'eslint-plugin-import-x'; +import jest from 'eslint-plugin-jest'; +import node from 'eslint-plugin-n'; +import perfectionist from 'eslint-plugin-perfectionist'; +import regexp from 'eslint-plugin-regexp'; +import security from 'eslint-plugin-security'; +import sonarjs from 'eslint-plugin-sonarjs'; +import { defineConfig, globalIgnores } from 'eslint/config'; import globals from 'globals'; import tseslint from 'typescript-eslint'; -export default tseslint.config( +const tests = ['**/*.spec.ts', 'test/**/*.ts']; + +export default defineConfig( + globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']), { - ignores: ['eslint.config.mjs', 'dist/', 'generated/'], - }, - eslint.configs.recommended, - ...tseslint.configs.recommendedTypeChecked, - eslintPluginPrettierRecommended, - { - languageOptions: { - globals: { - ...globals.node, - ...globals.jest, - }, - sourceType: 'commonjs', - parserOptions: { - projectService: true, - tsconfigRootDir: import.meta.dirname, - }, + files: ['**/*.{ts,js,cjs,mjs}'], + extends: [ + eslint.configs.recommended, + node.configs['flat/recommended'], + importX.flatConfigs.recommended, + sonarjs.configs.recommended, + regexp.configs['flat/recommended'], + security.configs.recommended, + perfectionist.configs['recommended-natural'], + stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }), + ], + languageOptions: { globals: globals.node }, + linterOptions: { reportUnusedDisableDirectives: 'error' }, + settings: { + 'import-x/resolver-next': [createTypeScriptImportResolver()], + 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' }, + }, + rules: { + 'curly': ['error', 'multi-line'], + // This private application is deployed as a whole, never published to npm. + 'n/no-unpublished-import': 'off', + 'n/no-unpublished-require': 'off', + // Decorator execution and object insertion order can be observable at runtime. + 'perfectionist/sort-decorators': 'off', + 'perfectionist/sort-objects': 'off', + 'perfectionist/sort-modules': 'off', + 'perfectionist/sort-classes': 'off', + '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], + '@stylistic/arrow-parens': ['error', 'always'], + '@stylistic/operator-linebreak': ['error', 'after'], + 'import-x/no-duplicates': ['error', { 'prefer-inline': true }], }, }, { + files: ['**/*.mjs'], + extends: [node.configs['flat/recommended-module']], + }, + { + files: ['**/*.ts'], + extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], + languageOptions: { + sourceType: 'module', + parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname }, + }, rules: { + // TypeScript resolves source imports; import-x checks them with the TS resolver. + 'n/no-missing-import': 'off', 'no-magic-numbers': 'off', '@typescript-eslint/no-magic-numbers': [ 'error', @@ -63,24 +103,28 @@ export default tseslint.config( enforceConst: true, }, ], - '@typescript-eslint/no-explicit-any': 'off', '@typescript-eslint/no-non-null-assertion': 'error', - 'no-restricted-syntax': [ - 'error', - { - selector: - 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', - message: - 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', - }, - ], - '@typescript-eslint/no-floating-promises': 'warn', - '@typescript-eslint/no-unsafe-argument': 'warn', - 'prettier/prettier': ['error', { endOfLine: 'auto' }], + 'no-restricted-syntax': ['error', { + selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]', + message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.', + }], }, }, { - files: ['**/*.spec.ts', 'test/**/*.ts'], - rules: { '@typescript-eslint/no-magic-numbers': 'off' }, + files: ['src/**/*.ts'], + ignores: tests, + extends: [nest.configs.flatRecommended], + rules: { + // Domain string unions are validated with IsIn rather than runtime TS enums. + '@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }], + }, + }, + { + files: tests, + extends: [jest.configs['flat/recommended']], + rules: { + '@typescript-eslint/no-magic-numbers': 'off', + 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], + }, }, ); diff --git a/package-lock.json b/package-lock.json index 225e498..697eecf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,6 +26,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -38,12 +39,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -52,12 +54,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -66,7 +73,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "engines": { "node": "v24.21.0" @@ -1276,6 +1283,30 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@darraghor/eslint-plugin-nestjs-typed": { + "version": "7.5.6", + "resolved": "https://registry.npmjs.org/@darraghor/eslint-plugin-nestjs-typed/-/eslint-plugin-nestjs-typed-7.5.6.tgz", + "integrity": "sha512-G8QbQX0Xd2gj9ZrRXE+8RCeXivEVNRt9DggvxIfHA9aBOMPrp+RHdvtxWViNqaJMezhSxD+3FFuLE3vZ6el0GQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "@typescript-eslint/scope-manager": "^8.70.0", + "@typescript-eslint/type-utils": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "eslint-module-utils": "2.14.0", + "glob": "13.0.6", + "reflect-metadata": "0.2.2", + "ts-api-utils": "2.5.0" + }, + "engines": { + "node": ">=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^7.0.0 || ^8.0.0", + "class-validator": "*", + "eslint": ">=9.18.0" + } + }, "node_modules/@electric-sql/pglite": { "version": "0.4.3", "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.4.3.tgz", @@ -1459,43 +1490,6 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, - "node_modules/@eslint/eslintrc": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.7.tgz", - "integrity": "sha512-F42g89Qd5oAWtp0k0nnSrjziAKza7w8SVT4mStc18LZMaRb4J1HQAHLCalEtDCxrTuksx7NU9qsmeLwpOfPqWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "ajv": "^6.14.0", - "debug": "^4.3.2", - "espree": "^10.0.1", - "globals": "^14.0.0", - "ignore": "^5.2.0", - "import-fresh": "^3.2.1", - "js-yaml": "^4.3.2", - "minimatch": "^3.1.5", - "strip-json-comments": "^3.1.1" - }, - "engines": { - "node": "^18.18.0 || ^20.9.0 || >=21.1.0" - }, - "funding": { - "url": "https://opencollective.com/eslint" - } - }, - "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "14.0.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", - "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@eslint/js": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", @@ -4374,12 +4368,40 @@ "@sinonjs/commons": "^3.0.1" } }, + "node_modules/@sonarsource/analyzer-commons-configurations": { + "version": "2.31.0-5284", + "resolved": "https://registry.npmjs.org/@sonarsource/analyzer-commons-configurations/-/analyzer-commons-configurations-2.31.0-5284.tgz", + "integrity": "sha512-ruk3blMs9BE9RNSp9LPpWZAIjxJmq4Cp00E3cELIHz7Kym3WxdWGRlKOMYLRQ5tJg/de7jN5AtEElY3bGIfTcw==", + "dev": true, + "license": "LGPL-3.0-only" + }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", "license": "MIT" }, + "node_modules/@stylistic/eslint-plugin": { + "version": "5.10.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-5.10.0.tgz", + "integrity": "sha512-nPK52ZHvot8Ju/0A4ucSX1dcPV2/1clx0kLcH5wDmrE4naKso7TUC/voUyU1O9OTKTrR6MYip6LP0ogEMQ9jPQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/types": "^8.56.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", + "estraverse": "^5.3.0", + "picomatch": "^4.0.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "peerDependencies": { + "eslint": "^9.0.0 || ^10.0.0" + } + }, "node_modules/@tokenizer/inflate": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/@tokenizer/inflate/-/inflate-0.4.1.tgz", @@ -4886,17 +4908,17 @@ "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.0.tgz", - "integrity": "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.70.1.tgz", + "integrity": "sha512-nDNrUQ/4ruSNYbu749TRY7cfrzPtoLHEXSNBI8aaNY32LlZCajixqRf3FqcKC4p5Cam4VOHYx/t+i5+nKXvrqA==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.12.2", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/type-utils": "8.70.0", - "@typescript-eslint/utils": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/type-utils": "8.70.1", + "@typescript-eslint/utils": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" @@ -4909,7 +4931,7 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.70.0", + "@typescript-eslint/parser": "^8.70.1", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } @@ -4925,16 +4947,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.0.tgz", - "integrity": "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.70.1.tgz", + "integrity": "sha512-nO974WLllwhSFWQXnMLj6nDGa8f0khKEz1JzpPJ1u7Vm/4X1X6ZHajpoknU4bb41vJyMB0HHVyS2GqdhWfIXZw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4950,14 +4972,14 @@ } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.0.tgz", - "integrity": "sha512-hFHbTNqhU9G+2eKFXCBVb1tjFT/LceiJ4+HfLO4pTpDI0KHi6iajpcFFkaSQ9gXmCh7n82A0PthaayEdN6mspQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.70.1.tgz", + "integrity": "sha512-62xOgboPfwc3/IgPSX/W6oQR3ZbF04194FPGUGH8HL8iLFHbt/456/8Ph1wLNUgVF+s94FlHoipBsz+v7+LMnA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.70.0", - "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/tsconfig-utils": "^8.70.1", + "@typescript-eslint/types": "^8.70.1", "debug": "^4.4.3" }, "engines": { @@ -4972,14 +4994,14 @@ } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.0.tgz", - "integrity": "sha512-8nP3Kwh5hlgZ4FicGvmznAmJe8UL4sdU8tLukrPaMuQmDuk4Y8xYfzu/aYZW4xT2JCgc7H/TpDI5cGlxcWJSqQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.70.1.tgz", + "integrity": "sha512-Pa0EeSeAusQc1WbjQMac+YfenewYTBu0KjgYvkUKwhXaHUKbFog23Dm/rp0DX/6tyYOQ3Xl1a+3EcFNZynGHCw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0" + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4990,9 +5012,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.0.tgz", - "integrity": "sha512-adnkeeNq9Sq1sUf4+FRVc0KdgYghzsgFpZSQVZVvY0LCuUuN0FnQgyGzCJeC4fW1cdXseBAjU2EOqUIjbNcZUw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.70.1.tgz", + "integrity": "sha512-jumze1fPI+sDOaM2TWGQdn39PDxTr7TZGeuyLkAbNyx2vtMT3uRnVKChN0hfht5V2TugphJzF6bYXvBcE09qqg==", "dev": true, "license": "MIT", "engines": { @@ -5007,15 +5029,15 @@ } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.0.tgz", - "integrity": "sha512-NUMKIhYVaVIVLnRL9CRt+VVcuLgSHUCpXn4/+K8wql+vdInUzvx8BjUO1oJ7cG9shjFJKtF8F8Hh2kCh3/KBVw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.70.1.tgz", + "integrity": "sha512-7zKTnyvaVWqzLZHPFQtX1hVHqgkMC+WebPWakNCSyrQVbIP1AM0L0TlBZtACldIRb6PptI8Odk+jyZ5kP3B1VA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, @@ -5032,9 +5054,9 @@ } }, "node_modules/@typescript-eslint/types": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.0.tgz", - "integrity": "sha512-asTOIYhDg4zdzOScCyaytrsV3cR6B4ecPQlXw/dJIm7J/MZTtCtfVII9JD8Geh4jTCrK/Xe6cg5UevoleMcoJQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.70.1.tgz", + "integrity": "sha512-Dm1ypdhhrGCTyyehxElhgJ6kgk8MVCv5qXdoOVqPr1uqk42jX8KjrZqhROvdShczA8qrDoYiOWn1ykWlx2k81Q==", "dev": true, "license": "MIT", "engines": { @@ -5046,16 +5068,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.0.tgz", - "integrity": "sha512-d9NmHMPEKQ7QCLLm1jI3zmoQBwT5KwFYjXBJ9ymZfKCUU+5rmTRykKAFvH5Qn/ZCds3CEAFS9OC9M/jkl0X2bA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.70.1.tgz", + "integrity": "sha512-TU8PwyGN0PQJUcE96mw8eCQ44SmxGdQlJmlWakHaHQ15eIuuvye5yNtmh/i6oS88jzXVQB71xdNkbkB/fMwL0g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.70.0", - "@typescript-eslint/tsconfig-utils": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/visitor-keys": "8.70.0", + "@typescript-eslint/project-service": "8.70.1", + "@typescript-eslint/tsconfig-utils": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/visitor-keys": "8.70.1", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", @@ -5084,9 +5106,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dev": true, "license": "MIT", "dependencies": { @@ -5113,16 +5135,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.0.tgz", - "integrity": "sha512-oZmtKJz/4fufZ2p3+Cn3ijEojcdfR+1zYDH2xKYrEly0dR/Q/1xUPRCOlKGxod78nWlU2UnDe09GZ3TaknBFGA==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.70.1.tgz", + "integrity": "sha512-Esgul8MsnKnRLdYU2Eb2cRV9bS5HJYtKj1ByJnOzzG2M58DGdSUQ1jUuILxipqcpB2h9WLrbD5GijIWUjX/Tqw==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", - "@typescript-eslint/scope-manager": "8.70.0", - "@typescript-eslint/types": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0" + "@typescript-eslint/scope-manager": "8.70.1", + "@typescript-eslint/types": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -5137,13 +5159,13 @@ } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.0.tgz", - "integrity": "sha512-BoC8PiO4Hkdo0TVJh9Ntxr5MxPDI7/oFsrygN5ADelFSeXG/qgNuucIGA+L5Z6JpPTE/uRfcTWtscjbUaufepQ==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.70.1.tgz", + "integrity": "sha512-Vwj9lUIW5Xq3wQ9w6gv3R86g1hMK8f2zNOdGTAgeXUMMXFK78G9ruCjjqutHMNJc0+CH7LYRnHeUB9IT8wFmcw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.70.0", + "@typescript-eslint/types": "8.70.1", "eslint-visitor-keys": "^5.0.0" }, "engines": { @@ -6248,13 +6270,6 @@ "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" } }, - "node_modules/balanced-match": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", - "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", - "dev": true, - "license": "MIT" - }, "node_modules/base64-js": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", @@ -6400,17 +6415,6 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "license": "ISC" }, - "node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", - "dev": true, - "license": "MIT", - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/browserslist": { "version": "4.28.8", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", @@ -6504,6 +6508,19 @@ "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "license": "MIT" }, + "node_modules/builtin-modules": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-3.3.0.tgz", + "integrity": "sha512-zhaCDicdLuWN5UbN5IMnFqNMhNfo919sH85y2/ea+5Yg9TsTkeZxpL+JLbp6cgYFS4sRLp3YV4S6yDuqVWHYOw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/bundle-name": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", @@ -6978,6 +6995,16 @@ "node": ">= 6" } }, + "node_modules/comment-parser": { + "version": "1.4.9", + "resolved": "https://registry.npmjs.org/comment-parser/-/comment-parser-1.4.9.tgz", + "integrity": "sha512-+2AvZKjJaNq9GQljm3tr0utwrig5BL+jgJGvz5rDpGKNIp+ojcRXWUwBbh/sGIi1QCprR6PsKFakub+w1v+4hQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 12.0.0" + } + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -6988,13 +7015,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/concat-map": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", - "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, - "license": "MIT" - }, "node_modules/concat-stream": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", @@ -7948,7 +7968,6 @@ "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" @@ -8118,9 +8137,9 @@ } }, "node_modules/eslint": { - "version": "10.10.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.10.0.tgz", - "integrity": "sha512-NPXn6r5zl4uET1DAVPaOwzX3rut4c0wcmw3dWJAfOsTM5+TogXo0DDjz8pwm/hL8cyVNpHqeK4JpN0NjnyFFNw==", + "version": "10.11.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.11.0.tgz", + "integrity": "sha512-P7a6UEEqb9G95MYAtqkmsTbVXIYyzIfl6NGOIJk162PaahFxFyeGcrlXYFSiagECg4sEm8IseJdZBKR3rx6MsQ==", "dev": true, "license": "MIT", "workspaces": [ @@ -8176,53 +8195,437 @@ } } }, - "node_modules/eslint-config-prettier": { - "version": "10.1.8", - "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", - "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", - "dev": true, - "license": "MIT", - "bin": { - "eslint-config-prettier": "bin/cli.js" - }, - "funding": { - "url": "https://opencollective.com/eslint-config-prettier" - }, - "peerDependencies": { - "eslint": ">=7.0.0" - } - }, - "node_modules/eslint-plugin-prettier": { - "version": "5.5.6", - "resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz", - "integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==", + "node_modules/eslint-compat-utils": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/eslint-compat-utils/-/eslint-compat-utils-0.5.1.tgz", + "integrity": "sha512-3z3vFexKIEnjHE3zCMRo6fn/e44U7T1khUjg+Hp0ZQMCigh28rALD0nPFBcGZuiLC5rLZa2ubQHDRln09JfU2Q==", "dev": true, "license": "MIT", "dependencies": { - "prettier-linter-helpers": "^1.0.1", - "synckit": "^0.11.13" + "semver": "^7.5.4" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "eslint": ">=6.0.0" + } + }, + "node_modules/eslint-import-context": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/eslint-import-context/-/eslint-import-context-0.1.9.tgz", + "integrity": "sha512-K9Hb+yRaGAGUbwjhFNHvSmmkZs9+zbuoe3kFQ4V1wYjrepUFYM2dZAfNtjbbj3qsPfUfsA68Bx/ICWQMi+C8Eg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-tsconfig": "^4.10.1", + "stable-hash-x": "^0.2.0" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-context" + }, + "peerDependencies": { + "unrs-resolver": "^1.0.0" + }, + "peerDependenciesMeta": { + "unrs-resolver": { + "optional": true + } + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-4.4.5.tgz", + "integrity": "sha512-nbE5XLph6TLtGYcu/U6e6ZVXyKBhbDWK5cLGk76eJ7NdZpwf1P9EFkpt1Z01mNZNrrilsAYWKH6zUkL4reoXbw==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.4.1", + "eslint-import-context": "^0.1.8", + "get-tsconfig": "^4.10.1", + "is-bun-module": "^2.0.0", + "stable-hash-x": "^0.2.0", + "tinyglobby": "^0.2.14", + "unrs-resolver": "^1.7.11" + }, + "engines": { + "node": "^16.17.0 || >=18.6.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.14.0.tgz", + "integrity": "sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-es-x": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-es-x/-/eslint-plugin-es-x-7.8.0.tgz", + "integrity": "sha512-7Ds8+wAAoV3T+LAKeu39Y5BzXCrGKrcISfgKEqTS4BDN8SFEDQd0S43jiQ8vIa3wUKD07qitZdfzlenSi8/0qQ==", + "dev": true, + "funding": [ + "https://github.com/sponsors/ota-meshi", + "https://opencollective.com/eslint" + ], + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.1.2", + "@eslint-community/regexpp": "^4.11.0", + "eslint-compat-utils": "^0.5.1" }, "engines": { "node": "^14.18.0 || >=16.0.0" }, + "peerDependencies": { + "eslint": ">=8" + } + }, + "node_modules/eslint-plugin-import-x": { + "version": "4.17.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-import-x/-/eslint-plugin-import-x-4.17.1.tgz", + "integrity": "sha512-4cdstYkKCyjumM2Q9NSI03K8D2a9F4Ssz33K2lv2hQa4KmR9jPLwk3uWGtNvclfqBrPGfGuMBwsGMbe6dMRbfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.56.0", + "comment-parser": "^1.4.1", + "debug": "^4.4.1", + "eslint-import-context": "^0.1.9", + "is-glob": "^4.0.3", + "minimatch": "^9.0.3 || ^10.1.2", + "semver": "^7.7.2", + "stable-hash-x": "^0.2.0", + "unrs-resolver": "^1.9.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, "funding": { - "url": "https://opencollective.com/eslint-plugin-prettier" + "url": "https://opencollective.com/eslint-plugin-import-x" }, "peerDependencies": { - "@types/eslint": ">=8.0.0", - "eslint": ">=8.0.0", - "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", - "prettier": ">=3.0.0" + "@typescript-eslint/utils": "^8.56.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "eslint-import-resolver-node": "*" }, "peerDependenciesMeta": { - "@types/eslint": { + "@typescript-eslint/utils": { "optional": true }, - "eslint-config-prettier": { + "eslint-import-resolver-node": { "optional": true } } }, + "node_modules/eslint-plugin-import-x/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-import-x/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/eslint-plugin-jest": { + "version": "29.16.6", + "resolved": "https://registry.npmjs.org/eslint-plugin-jest/-/eslint-plugin-jest-29.16.6.tgz", + "integrity": "sha512-q8TVr0rlNvUD0XnafGWkwtPeX+tTl2llP86EDl3sJtwWrQA/JT9SIu2hLLZbhhX6fT5SDE4O0gIKyZUujKnkYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/utils": "^8.0.0" + }, + "engines": { + "node": "^20.12.0 || ^22.0.0 || >=24.0.0" + }, + "peerDependencies": { + "@typescript-eslint/eslint-plugin": "^8.0.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "jest": "*", + "typescript": ">=4.8.4 <8.0.0" + }, + "peerDependenciesMeta": { + "@typescript-eslint/eslint-plugin": { + "optional": true + }, + "jest": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n": { + "version": "18.4.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-n/-/eslint-plugin-n-18.4.0.tgz", + "integrity": "sha512-TEm6Nqn9+l7EWfJXOL6pmrEdpiM2ZTgo4fNMRDvBTM70jeQ7x3X3Akcx+sRCIRfpcSVwMypH3ZAA33PLyNKmYw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.5.0", + "enhanced-resolve": "^5.17.1", + "eslint-plugin-es-x": "^7.8.0", + "get-tsconfig": "^4.8.1", + "globals": "^15.11.0", + "globrex": "^0.1.2", + "ignore": "^5.3.2", + "js-yaml": "^5.4.1", + "semver": "^7.6.3" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": ">=8.57.1", + "ts-declaration-location": "^1.0.6", + "typescript": ">=5.0.0" + }, + "peerDependenciesMeta": { + "ts-declaration-location": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-n/node_modules/globals": { + "version": "15.15.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-15.15.0.tgz", + "integrity": "sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-plugin-n/node_modules/js-yaml": { + "version": "5.4.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.4.2.tgz", + "integrity": "sha512-m+aqu+LwO1O6sIopafj8HUVl5aawITwZQe/yHpMCKjaWBaA/d07B/QdMb3529REftiU+RMMHL3Vlsw3hON7vWg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.mjs" + } + }, + "node_modules/eslint-plugin-perfectionist": { + "version": "5.12.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-perfectionist/-/eslint-plugin-perfectionist-5.12.1.tgz", + "integrity": "sha512-krdSE7Dz+TCtcVr2+rfNhlOIFFBJcUdoRvjaN7JQnsaVnxJw+BZh9wMkY3MSLCJat+yW+UPTQLKS82Epc7MGZA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "^8.70.0", + "@typescript-eslint/utils": "^8.70.0", + "natural-orderby": "^5.0.0" + }, + "engines": { + "node": "^20.0.0 || >=22.0.0" + }, + "peerDependencies": { + "eslint": "^8.45.0 || ^9.0.0 || ^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-regexp": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-regexp/-/eslint-plugin-regexp-3.3.1.tgz", + "integrity": "sha512-58bGUci/AaWAo1ETj6Lsnmy6QXwnSyVONRA805828nnqp7vrTwdaQMc02vtowmYB3A5OB3SLr3r5HgmfxX4dLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/regexpp": "^4.11.0", + "comment-parser": "^1.4.0", + "jsdoc-type-pratt-parser": "^9.0.0", + "refa": "^0.12.1", + "regexp-ast-analysis": "^0.7.1", + "scslre": "^0.3.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "peerDependencies": { + "eslint": ">=9.38.0" + } + }, + "node_modules/eslint-plugin-security": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-security/-/eslint-plugin-security-4.1.0.tgz", + "integrity": "sha512-cB4H72o/w8C0cm4uHJOnXMJz7MYTTuOwhn2X7PMuzZwfwqUoHK/ASHT6WKP+95lFk5Hkf3xfpiXB8a1+cef4rg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-regex": "^2.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-plugin-sonarjs": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-sonarjs/-/eslint-plugin-sonarjs-4.2.1.tgz", + "integrity": "sha512-WGsnVkUxmHyBOytShcIeE+vhQaS8e+gYgbHNNi4y2e5Zj+DpE2DLTvarqDUhNwjceTZHGLgevSgfBSEVkt4EaA==", + "dev": true, + "license": "LGPL-3.0-only", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@sonarsource/analyzer-commons-configurations": "^2.31.0-5284", + "builtin-modules": "^3.3.0", + "bytes": "^3.1.2", + "functional-red-black-tree": "^1.0.1", + "globals": "^17.12.0", + "jsx-ast-utils-x": "^0.1.0", + "lodash.merge": "^4.6.2", + "minimatch": "^10.2.6", + "scslre": "^0.3.0", + "semver": "^7.8.5", + "ts-api-utils": "^2.5.0", + "typescript": ">=5 <6.1.0", + "yaml": "^2.9.0" + }, + "peerDependencies": { + "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { + "version": "10.2.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", + "integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/eslint-scope": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", @@ -8603,13 +9006,6 @@ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", "license": "MIT" }, - "node_modules/fast-diff": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", - "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/fast-json-stable-stringify": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", @@ -8932,6 +9328,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha512-dsKNQNdj6xA3T+QlADDA7mOSlX0qiMINjn0cgr+eGHGsbSHzTabcIogz2+p/iqP1Xs6EP/sS2SbqH+brGTbq0g==", + "dev": true, + "license": "MIT" + }, "node_modules/generate-function": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", @@ -9039,6 +9442,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/get-tsconfig": { + "version": "4.14.3", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.3.tgz", + "integrity": "sha512-++QEw4DIY7WGoukz+/+A/8dGYPT9l9yIadnmSgZ8Rjr3YVSVDipQSO9CdnJo9ePqFqUUqh+wk9uIaoiAwsiPkA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, "node_modules/get-uri": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/get-uri/-/get-uri-8.0.1.tgz", @@ -9199,6 +9615,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/globrex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/globrex/-/globrex-0.1.2.tgz", + "integrity": "sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -9569,6 +9992,16 @@ "dev": true, "license": "MIT" }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, "node_modules/is-docker": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", @@ -10468,6 +10901,37 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdoc-type-pratt-parser": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/jsdoc-type-pratt-parser/-/jsdoc-type-pratt-parser-9.2.2.tgz", + "integrity": "sha512-eFY7Rlscvv/BpY6UsVsjKwMhu1tJO/F4U/vC9g3yeRy/JqszuO1UP6Vdq+85G+3FP6fEw/sWR8a5ftPFY86csw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.9", + "@types/node": "^26.4.0" + }, + "engines": { + "node": "^22.22.2 || >=24.15.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/@types/node": { + "version": "26.6.3", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.3.tgz", + "integrity": "sha512-dsqMQQoeTLqu9wynDD00q573mNzso3IdQOAfHRJqLCcmCFPoGo9A1bDpUcv/9tnKpErQWv9uKeGfl37EIS02Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/jsdoc-type-pratt-parser/node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -10551,6 +11015,16 @@ "npm": ">=6" } }, + "node_modules/jsx-ast-utils-x": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/jsx-ast-utils-x/-/jsx-ast-utils-x-0.1.0.tgz", + "integrity": "sha512-eQQBjBnsVtGacsG9uJNB8qOr3yA8rga4wAaGG1qRcBzSIvfhERLrWxMAM1hp5fcS6Abo8M4+bUBTekYR0qTPQw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -10960,19 +11434,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/minimatch": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", - "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", - "dev": true, - "license": "ISC", - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, "node_modules/minimist": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", @@ -11313,6 +11774,16 @@ "dev": true, "license": "MIT" }, + "node_modules/natural-orderby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/natural-orderby/-/natural-orderby-5.0.0.tgz", + "integrity": "sha512-kKHJhxwpR/Okycz4HhQKKlhWe4ASEfPgkSWNmKFHd7+ezuQlxkA5cM3+XkBPvm1gmHen3w53qsYAv+8GwRrBlg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -12232,35 +12703,6 @@ "node": ">= 0.8.0" } }, - "node_modules/prettier": { - "version": "3.9.6", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.6.tgz", - "integrity": "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==", - "dev": true, - "license": "MIT", - "bin": { - "prettier": "bin/prettier.cjs" - }, - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/prettier/prettier?sponsor=1" - } - }, - "node_modules/prettier-linter-helpers": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/prettier-linter-helpers/-/prettier-linter-helpers-1.0.1.tgz", - "integrity": "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fast-diff": "^1.1.2" - }, - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/pretty-format": { "version": "30.5.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.5.1.tgz", @@ -12651,12 +13093,49 @@ "node": ">= 12.13.0" } }, + "node_modules/refa": { + "version": "0.12.1", + "resolved": "https://registry.npmjs.org/refa/-/refa-0.12.1.tgz", + "integrity": "sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, "node_modules/reflect-metadata": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz", "integrity": "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q==", "license": "Apache-2.0" }, + "node_modules/regexp-ast-analysis": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/regexp-ast-analysis/-/regexp-ast-analysis-0.7.1.tgz", + "integrity": "sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.1" + }, + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/regexp-tree": { + "version": "0.1.27", + "resolved": "https://registry.npmjs.org/regexp-tree/-/regexp-tree-0.1.27.tgz", + "integrity": "sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==", + "dev": true, + "license": "MIT", + "bin": { + "regexp-tree": "bin/regexp-tree" + } + }, "node_modules/release-it": { "version": "21.0.2", "resolved": "https://registry.npmjs.org/release-it/-/release-it-21.0.2.tgz", @@ -12804,6 +13283,16 @@ "node": ">=4" } }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, "node_modules/restore-cursor": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz", @@ -12919,6 +13408,16 @@ ], "license": "MIT" }, + "node_modules/safe-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-2.1.1.tgz", + "integrity": "sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==", + "dev": true, + "license": "MIT", + "dependencies": { + "regexp-tree": "~0.1.1" + } + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -12963,6 +13462,21 @@ "license": "MIT", "peer": true }, + "node_modules/scslre": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/scslre/-/scslre-0.3.0.tgz", + "integrity": "sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.8.0", + "refa": "^0.12.0", + "regexp-ast-analysis": "^0.7.0" + }, + "engines": { + "node": "^14.0.0 || >=16.0.0" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -13311,6 +13825,16 @@ "url": "https://github.com/mysqljs/sql-escaper?sponsor=1" } }, + "node_modules/stable-hash-x": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/stable-hash-x/-/stable-hash-x-0.2.0.tgz", + "integrity": "sha512-o3yWv49B/o4QZk5ZcsALc6t0+eCelPc44zZsLtCQnZPDwFpDYSWcDnrv2TtMmMbQ7uKo3J0HTURCqckw23czNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/stack-utils": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz", @@ -13578,7 +14102,6 @@ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=6" }, @@ -14127,16 +14650,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.70.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.0.tgz", - "integrity": "sha512-P/W5cz70/cQAuKfY3xwQMWWTV7BvJ0mAQmi+9mBcsVPaBUpd6Ohpa+fECv9rBFrQcig86jAiNBFNWUqnTjr4pw==", + "version": "8.70.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.70.1.tgz", + "integrity": "sha512-AcWG7KDjZ2THNXsgwttMaGmzVi0VFRlFYfqFHYQRbDpF3owuYbuiL8c7UUrd2k8s3PoSfIQrWfrGXfcElrWLYA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.70.0", - "@typescript-eslint/parser": "8.70.0", - "@typescript-eslint/typescript-estree": "8.70.0", - "@typescript-eslint/utils": "8.70.0" + "@typescript-eslint/eslint-plugin": "8.70.1", + "@typescript-eslint/parser": "8.70.1", + "@typescript-eslint/typescript-estree": "8.70.1", + "@typescript-eslint/utils": "8.70.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -14832,6 +15355,22 @@ "dev": true, "license": "ISC" }, + "node_modules/yaml": { + "version": "2.9.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.1.tgz", + "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", + "dev": true, + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, "node_modules/yargs": { "version": "17.7.3", "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.3.tgz", diff --git a/package.json b/package.json index d849ebf..9ef6d3c 100644 --- a/package.json +++ b/package.json @@ -11,14 +11,14 @@ "scripts": { "build": "prisma generate && nest build", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs", - "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", + "format": "npm run lint:fix", "start": "nest start", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:prod": "node dist/src/main.js", "lint": "npm run lint:check", - "lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", - "lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", + "lint:check": "eslint . --max-warnings=0", + "lint:fix": "eslint . --fix --max-warnings=0", "typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", @@ -30,10 +30,11 @@ "test:container": "node test/container-smoke.cjs", "test:durability": "node test/bank-cards-durability.cjs", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", - "verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", + "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", "release:preview": "release-it --dry-run --ci", "release:prepare": "release-it --ci", - "commitlint": "commitlint" + "commitlint": "commitlint", + "test:lint": "node --test test/eslint-config.cjs" }, "dependencies": { "@grpc/grpc-js": "1.14.4", @@ -53,6 +54,7 @@ "class-transformer": "0.5.1", "class-validator": "0.15.1", "cookie-parser": "1.4.7", + "dotenv": "17.4.2", "lru-cache": "11.5.2", "nestjs-pino": "5.1.0", "passport": "0.7.0", @@ -65,12 +67,13 @@ "devDependencies": { "@commitlint/cli": "21.2.2", "@commitlint/config-conventional": "21.2.2", - "@eslint/eslintrc": "3.3.7", + "@darraghor/eslint-plugin-nestjs-typed": "7.5.6", "@eslint/js": "10.0.1", "@nestjs/cli": "12.0.0", "@nestjs/schematics": "12.0.1", "@nestjs/testing": "12.0.1", "@release-it/conventional-changelog": "12.0.0", + "@stylistic/eslint-plugin": "5.10.0", "@types/bcrypt": "6.0.0", "@types/cookie-parser": "1.4.10", "@types/express": "5.0.6", @@ -79,12 +82,17 @@ "@types/passport-jwt": "4.0.1", "@types/supertest": "7.2.1", "conventional-changelog-conventionalcommits": "10.4.0", - "eslint": "10.10.0", - "eslint-config-prettier": "10.1.8", - "eslint-plugin-prettier": "5.5.6", + "eslint": "10.11.0", + "eslint-import-resolver-typescript": "4.4.5", + "eslint-plugin-import-x": "4.17.1", + "eslint-plugin-jest": "29.16.6", + "eslint-plugin-n": "18.4.0", + "eslint-plugin-perfectionist": "5.12.1", + "eslint-plugin-regexp": "3.3.1", + "eslint-plugin-security": "4.1.0", + "eslint-plugin-sonarjs": "4.2.1", "globals": "17.12.0", "jest": "30.5.1", - "prettier": "3.9.6", "release-it": "21.0.2", "source-map-support": "0.5.21", "supertest": "7.2.2", @@ -93,7 +101,7 @@ "ts-node": "10.9.2", "tsconfig-paths": "4.2.0", "typescript": "6.0.3", - "typescript-eslint": "8.70.0" + "typescript-eslint": "8.70.1" }, "overrides": { "@nestjs/platform-express": { diff --git a/scripts/check-release.cjs b/scripts/check-release.cjs index a2c957b..2a6c2b3 100644 --- a/scripts/check-release.cjs +++ b/scripts/check-release.cjs @@ -1,10 +1,12 @@ -const assert = require("node:assert/strict"); -const { execFileSync } = require("node:child_process"); -const { version } = require("../package.json"); -const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim(); +const assert = require('node:assert/strict'); +const { execFileSync } = require('node:child_process'); -assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); -const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") - .split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); -assert.equal(version, tags[0]?.slice(1) ?? "0.0.0", - "Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another."); +const { version } = require('../package.json'); +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. +const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); + +assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release'); +const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname') + .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); +assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', + 'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.'); diff --git a/scripts/generate-openapi.cjs b/scripts/generate-openapi.cjs index 3845d46..ad8b646 100644 --- a/scripts/generate-openapi.cjs +++ b/scripts/generate-openapi.cjs @@ -15,6 +15,7 @@ Object.assign(process.env, { }); const { NestFactory } = require('@nestjs/core'); + const { AppModule } = require('../dist/src/app.module.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); diff --git a/src/app.module.ts b/src/app.module.ts index a5e7443..02a572d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,8 +1,8 @@ -import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { LoggerModule } from 'nestjs-pino'; import { randomUUID } from 'node:crypto'; + import { LoggingModule } from './common/logging/logging.module'; import { Environment, validateEnv } from './config/env.validation'; import { AuthModule } from './modules/auth/auth.module'; @@ -13,6 +13,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments import { OperationsModule } from './modules/investments/operations/operations.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; +import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { PrismaModule } from './prisma/prisma.module'; diff --git a/src/common/decimal.ts b/src/common/decimal.ts index 998e7a5..9f969b6 100644 --- a/src/common/decimal.ts +++ b/src/common/decimal.ts @@ -1,8 +1,10 @@ -export type Decimal = { value: bigint; scale: number }; +export type Decimal = { scale: number; value: bigint }; export function decimalFromString(value: string): Decimal { - if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. + if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) { throw new Error('Invalid decimal string'); + } const negative = value.startsWith('-'); const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); return { @@ -46,8 +48,12 @@ export function formatDecimal({ value, scale }: Decimal): string { .toString() .padStart(scale + 1, '0'); const whole = digits.slice(0, -scale) || '0'; - const fraction = digits.slice(-scale).replace(/0+$/, ''); - return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; + const rawFraction = digits.slice(-scale); + let fractionEnd = rawFraction.length; + while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1; + const fraction = rawFraction.slice(0, fractionEnd); + const fractionalPart = fraction ? '.' + fraction : ''; + return `${sign}${whole}${fractionalPart}`; } // Round half away from zero; callers choose presentation precision explicitly. @@ -55,7 +61,7 @@ export function divide(left: Decimal, right: Decimal, scale: number): Decimal { if (right.value === 0n) throw new Error('Division by zero'); const numerator = left.value * 10n ** BigInt(right.scale + scale); const denominator = right.value * 10n ** BigInt(left.scale); - const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; + const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n; const n = numerator < 0n ? -numerator : numerator; const d = denominator < 0n ? -denominator : denominator; return { value: sign * ((n + d / 2n) / d), scale }; diff --git a/src/common/decorators/api-safe-error-response.decorator.ts b/src/common/decorators/api-safe-error-response.decorator.ts index d9f8947..81300b4 100644 --- a/src/common/decorators/api-safe-error-response.decorator.ts +++ b/src/common/decorators/api-safe-error-response.decorator.ts @@ -1,7 +1,8 @@ -import { HttpStatus, applyDecorators } from '@nestjs/common'; +import { applyDecorators, HttpStatus } from '@nestjs/common'; import { ApiExtraModels, ApiProperty, + ApiPropertyOptional, ApiResponse, getSchemaPath, } from '@nestjs/swagger'; @@ -13,8 +14,8 @@ export class ProblemDetailsDto { @ApiProperty({ example: 'UNAUTHORIZED' }) declare code: string; - @ApiProperty({ - required: false, + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. + @ApiPropertyOptional({ type: 'array', items: { type: 'object', @@ -23,7 +24,7 @@ export class ProblemDetailsDto { }, example: [{ pointer: '#/email', detail: 'must be a valid email address' }], }) - errors?: { pointer: string; detail: string }[]; + errors?: { detail: string; pointer: string }[]; } function problemResponse(status: number, description: string) { @@ -38,7 +39,7 @@ function problemResponse(status: number, description: string) { }); } -export function ApiProblemResponses(): MethodDecorator & ClassDecorator { +export function ApiProblemResponses(): ClassDecorator & MethodDecorator { return applyDecorators( ApiExtraModels(ProblemDetailsDto), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), diff --git a/src/common/dto-validation.spec.ts b/src/common/dto-validation.spec.ts index 93016de..942fd3d 100644 --- a/src/common/dto-validation.spec.ts +++ b/src/common/dto-validation.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException, ValidationPipe } from '@nestjs/common'; + import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CreateBankCardDto, @@ -6,7 +7,8 @@ import { } from '../modules/banking/bank-cards/presentation/bank-card.dto'; describe('DTO validation', () => { - const pipe = new ValidationPipe({ transform: true, whitelist: true }); + const pipe = new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true }); it('requires credentials and normalizes a valid email', async () => { const metadata = { type: 'body' as const, metatype: CredentialsDto }; diff --git a/src/common/filters/http-exception.filter.spec.ts b/src/common/filters/http-exception.filter.spec.ts index 0a10828..231cddb 100644 --- a/src/common/filters/http-exception.filter.spec.ts +++ b/src/common/filters/http-exception.filter.spec.ts @@ -1,4 +1,5 @@ import { ArgumentsHost } from '@nestjs/common'; + import { HttpExceptionFilter } from './http-exception.filter'; describe('HttpExceptionFilter', () => { diff --git a/src/common/filters/http-exception.filter.ts b/src/common/filters/http-exception.filter.ts index 0703a83..1e715f7 100644 --- a/src/common/filters/http-exception.filter.ts +++ b/src/common/filters/http-exception.filter.ts @@ -1,3 +1,5 @@ +import type { Response } from 'express'; + import { ArgumentsHost, Catch, @@ -6,7 +8,6 @@ import { HttpStatus, ValidationError, } from '@nestjs/common'; -import type { Response } from 'express'; @Catch() export class HttpExceptionFilter implements ExceptionFilter { @@ -14,32 +15,33 @@ export class HttpExceptionFilter implements ExceptionFilter { const context = host.switchToHttp(); const response = context.getResponse(); const status = - exception instanceof HttpException - ? exception.getStatus() - : HttpStatus.INTERNAL_SERVER_ERROR; + exception instanceof HttpException ? + exception.getStatus() : + HttpStatus.INTERNAL_SERVER_ERROR; const payload = exception instanceof HttpException ? exception.getResponse() : undefined; const isValidation = Array.isArray( (payload as { message?: unknown })?.message, ); - const code = isValidation - ? 'VALIDATION_ERROR' - : this.codeFor(status, payload); - (response.locals ??= {}).publicErrorCode = code; + const code = isValidation ? + 'VALIDATION_ERROR' : + this.codeFor(status, payload); + response.locals ??= {}; + response.locals.publicErrorCode = code; response .status(status) .type('application/problem+json') .json({ status, code, - ...(isValidation - ? { + ...(isValidation ? + { errors: this.validationErrors( (payload as { message: ValidationError[] }).message, ), - } - : {}), + } : + {}), }); } @@ -49,6 +51,7 @@ export class HttpExceptionFilter implements ExceptionFilter { if (typeof code === 'string') return code; } return ( + // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. { 400: 'BAD_REQUEST', 401: 'UNAUTHORIZED', @@ -62,7 +65,7 @@ export class HttpExceptionFilter implements ExceptionFilter { private validationErrors( errors: ValidationError[], prefix = '#', - ): { pointer: string; detail: string }[] { + ): { detail: string; pointer: string }[] { return errors.flatMap((error) => { if (!error || typeof error.property !== 'string') return []; const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; diff --git a/src/common/logging/logging.module.ts b/src/common/logging/logging.module.ts index 096d005..510d88e 100644 --- a/src/common/logging/logging.module.ts +++ b/src/common/logging/logging.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { RequestContextService } from './request-context.service'; import { RequestLoggingMiddleware } from './request-logging.middleware'; import { StructuredLogger } from './structured-logger.service'; diff --git a/src/common/logging/request-context.service.ts b/src/common/logging/request-context.service.ts index 2b25e10..828adc9 100644 --- a/src/common/logging/request-context.service.ts +++ b/src/common/logging/request-context.service.ts @@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common'; import { AsyncLocalStorage } from 'node:async_hooks'; export type RequestLogContext = { - requestId: string; - clientRequestId?: string; - userId?: string; brokerageAccountId?: string; + clientRequestId?: string; + requestId: string; + userId?: string; }; @Injectable() diff --git a/src/common/logging/request-logging.middleware.spec.ts b/src/common/logging/request-logging.middleware.spec.ts index 2c14cfa..780ab34 100644 --- a/src/common/logging/request-logging.middleware.spec.ts +++ b/src/common/logging/request-logging.middleware.spec.ts @@ -1,4 +1,5 @@ import { EventEmitter } from 'node:events'; + import { RequestLoggingMiddleware } from './request-logging.middleware'; describe('RequestLoggingMiddleware', () => { @@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => { const next = jest.fn(); const request: { - method: string; baseUrl: string; - route: { path: string }; header: jest.Mock; id?: string; + method: string; + route: { path: string }; } = { method: 'GET', baseUrl: '/api/v1', diff --git a/src/common/logging/request-logging.middleware.ts b/src/common/logging/request-logging.middleware.ts index cb6952c..10fcd9b 100644 --- a/src/common/logging/request-logging.middleware.ts +++ b/src/common/logging/request-logging.middleware.ts @@ -1,7 +1,9 @@ -import { constants as httpStatus } from 'node:http2'; -import { Injectable, NestMiddleware } from '@nestjs/common'; import type { NextFunction, Request, Response } from 'express'; + +import { Injectable, NestMiddleware } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; +import { constants as httpStatus } from 'node:http2'; + import { RequestContextService } from './request-context.service'; import { StructuredLogger } from './structured-logger.service'; @@ -31,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware { const routePath = (routeRequest as { route?: { path?: unknown } }).route ?.path; const route = - typeof routePath === 'string' - ? `${request.baseUrl}${routePath}` - : ''; + typeof routePath === 'string' ? + `${request.baseUrl}${routePath}` : + ''; const context = { method: request.method, route, status: response.statusCode, durationMs: Math.round(performance.now() - startedAt), - ...(response.locals.publicErrorCode - ? { code: response.locals.publicErrorCode as string } - : {}), + ...(response.locals.publicErrorCode ? + { code: response.locals.publicErrorCode as string } : + {}), }; const level = - response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR - ? 'error' - : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || - response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || - response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN - ? 'warn' - : 'info'; + response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ? + 'error' : + response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || + response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || + response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ? + 'warn' : + 'info'; this.logger.event(level, 'http.request.completed', context); }); diff --git a/src/common/logging/structured-logger.service.ts b/src/common/logging/structured-logger.service.ts index 6ec49cf..8e2fb71 100644 --- a/src/common/logging/structured-logger.service.ts +++ b/src/common/logging/structured-logger.service.ts @@ -1,27 +1,28 @@ import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { PinoLogger } from 'nestjs-pino'; + import { RequestContextService } from './request-context.service'; -type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -type LogContext = Record; +type LogLevel = 'debug' | 'error' | 'info' | 'warn'; +type LogContext = Record; const allowedFields = new Set([ - 'service', - 'environment', - 'requestId', - 'clientRequestId', - 'userId', - 'brokerageAccountId', - 'method', - 'route', - 'status', - 'durationMs', - 'code', - 'operation', - 'grpcCode', 'attempts', + 'brokerageAccountId', + 'clientRequestId', + 'code', 'count', + 'durationMs', + 'environment', 'found', + 'grpcCode', + 'method', + 'operation', + 'requestId', + 'route', + 'service', + 'status', + 'userId', ]); @Injectable() @@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown { message: string, context: LogContext = {}, ): void { + // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. this.pino[level]( this.safeContext({ ...this.requestContext.get(), ...context }), message, diff --git a/src/common/money.dto.ts b/src/common/money.dto.ts index 87feaad..fbd29df 100644 --- a/src/common/money.dto.ts +++ b/src/common/money.dto.ts @@ -1,4 +1,5 @@ import { ApiProperty } from '@nestjs/swagger'; + import type { Money } from './money.types'; export class MoneyDto implements Money { diff --git a/src/config/env.validation.ts b/src/config/env.validation.ts index a15f0c5..d9c929b 100644 --- a/src/config/env.validation.ts +++ b/src/config/env.validation.ts @@ -7,12 +7,12 @@ export interface Environment { DATABASE_URL: string; FRONTEND_ORIGIN: string; JWT_SECRET: string; + LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn'; PORT: number; - TOKEN_ENCRYPTION_KEY: string; TBANK_CA_CERT_PATH?: string; TBANK_GRPC_ENDPOINT: string; TBANK_RATE_LIMIT_PER_SECOND: number; - LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; + TOKEN_ENCRYPTION_KEY: string; } export function validateEnv(config: Record): Environment { @@ -71,7 +71,7 @@ export function validateEnv(config: Record): Environment { if ( typeof tokenEncryptionKey !== 'string' || - !/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || + !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) || Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES ) { throw new Error( @@ -92,7 +92,7 @@ export function validateEnv(config: Record): Environment { throw new Error('TBANK_CA_CERT_PATH must be a file path'); } - if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) { + if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) { throw new Error('LOG_LEVEL must be debug, info, warn, or error'); } @@ -102,9 +102,9 @@ export function validateEnv(config: Record): Environment { JWT_SECRET: jwtSecret, PORT: parsedPort, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, - ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath - ? { TBANK_CA_CERT_PATH: tbankCaCertPath } - : {}), + ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? + { TBANK_CA_CERT_PATH: tbankCaCertPath } : + {}), TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], diff --git a/src/integrations/moex/moex.client.spec.ts b/src/integrations/moex/moex.client.spec.ts index 46c2e58..6da226b 100644 --- a/src/integrations/moex/moex.client.spec.ts +++ b/src/integrations/moex/moex.client.spec.ts @@ -199,8 +199,9 @@ describe('MoexClient', () => { const url = new URL(input instanceof Request ? input.url : input); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(url.searchParams.get('start') ?? ''); - if (starts.length > 1) + if (starts.length > 1) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ @@ -228,13 +229,14 @@ describe('MoexClient', () => { const start = Number(url.searchParams.get('start')); if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { starts.push(String(start)); - if (starts.length > 2) + if (starts.length > 2) { return Promise.reject(new Error('Repeated page')); + } } return Promise.resolve( Response.json({ ...emptyEvents, - coupons: { + 'coupons': { columns: ['coupondate', 'value', 'faceunit'], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], }, @@ -246,11 +248,13 @@ describe('MoexClient', () => { ); }); const result = new MoexClient().bondEvents('BOND'); - if (repeatIndex) + if (repeatIndex) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).rejects.toMatchObject({ response: { code: 'MOEX_UNAVAILABLE' }, }); - else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. await expect(result).resolves.toEqual([ { type: 'coupon', @@ -265,6 +269,7 @@ describe('MoexClient', () => { currency: 'RUB', }, ]); + } expect(starts).toEqual(['0', '1']); }, ); diff --git a/src/integrations/moex/moex.client.ts b/src/integrations/moex/moex.client.ts index d00fbbf..eb6fc31 100644 --- a/src/integrations/moex/moex.client.ts +++ b/src/integrations/moex/moex.client.ts @@ -1,5 +1,5 @@ -import { constants as httpStatus } from 'node:http2'; import { Injectable, ServiceUnavailableException } from '@nestjs/common'; +import { constants as httpStatus } from 'node:http2'; const FALLBACK_PAGE_SIZE = 100; @@ -7,18 +7,18 @@ const baseUrl = 'https://iss.moex.com/iss'; const timeoutMs = 10_000; export type MoexBondEvent = - | { type: 'coupon'; date: string; value: string; currency: string } - | { type: 'maturity'; date: string; value: string; currency: string } - | { type: 'offer'; date: string }; + | { currency: string; date: string; type: 'coupon'; value: string } | + { currency: string; date: string; type: 'maturity'; value: string } | + { date: string; type: 'offer' }; type IssBlock = { columns?: unknown; data?: unknown }; type IssResponse = Record; export type MoexBondYield = { - value: string; - pricePercent: string; date: string; - dateType: string | null; + dateType: null | string; + pricePercent: string; tradeAt: string; + value: string; }; @Injectable() @@ -58,8 +58,9 @@ export class MoexClient { !Number.isFinite(Date.parse(date)) || !tradeAt || !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) - ) + ) { return null; + } const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; if (!Number.isFinite(Date.parse(tradeIso))) return null; return { @@ -70,6 +71,7 @@ export class MoexClient { tradeAt: new Date(tradeIso).toISOString(), }; } + async bondEvents(secid: string): Promise { const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const [coupons, offers, description] = await Promise.all([ @@ -86,8 +88,9 @@ export class MoexClient { const date = text(row.coupondate); const value = numberText(row.value); const currency = currencyText(row.faceunit); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'coupon', date, value, currency }); + } } for (const row of offers) { const date = text(row.offerdate); @@ -99,8 +102,9 @@ export class MoexClient { const date = values.get('MATDATE'); const value = numberText(values.get('FACEVALUE')); const currency = currencyText(values.get('FACEUNIT')); - if (date && value && currency) + if (date && value && currency) { events.push({ type: 'maturity', date, value, currency }); + } return events; } @@ -115,10 +119,11 @@ export class MoexClient { const query = new URLSearchParams({ 'iss.meta': 'off', 'iss.only': `${block},${block}.cursor`, - start: String(start), + 'start': String(start), }); const response = await this.request(`${baseUrl}/${path}?${query}`); if (!response) return rows; + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. const current = parseRows(response[block]); rows.push(...current); if (!paginated) return rows; @@ -140,8 +145,9 @@ export class MoexClient { pageSize === null || pageSize === 0 || current.length !== Math.min(pageSize, total - start) - ) + ) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } if (index + pageSize >= total) return rows; start = index + pageSize; } @@ -155,8 +161,9 @@ export class MoexClient { if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); const body: unknown = await response.json(); - if (!body || typeof body !== 'object') + if (!body || typeof body !== 'object') { throw new Error('MOEX malformed response'); + } return body as IssResponse; } catch { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); @@ -165,35 +172,40 @@ export class MoexClient { } function parseRows(block: IssBlock | undefined): Record[] { - if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) + if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } const columns = block.columns; - if (!columns.every((column) => typeof column === 'string')) + if (!columns.every((column) => typeof column === 'string')) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return block.data.map((values) => { - if (!Array.isArray(values) || values.length !== columns.length) + if (!Array.isArray(values) || values.length !== columns.length) { throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); + } return Object.fromEntries( + // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. columns.map((column, index) => [column.toLowerCase(), values[index]]), ); }); } -function text(value: unknown): string | null { +function text(value: unknown): null | string { return typeof value === 'string' && value.trim() ? value.trim() : null; } -function numberText(value: unknown): string | null { +function numberText(value: unknown): null | string { const result = typeof value === 'number' ? String(value) : text(value); + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; } -function currencyText(value: unknown): string | null { +function currencyText(value: unknown): null | string { const result = text(value)?.toUpperCase(); return result === 'SUR' ? 'RUB' : (result ?? null); } -function integer(value: unknown): number | null { +function integer(value: unknown): null | number { const result = Number(numberText(value) ?? NaN); return Number.isSafeInteger(result) && result >= 0 ? result : null; } diff --git a/src/integrations/moex/moex.module.ts b/src/integrations/moex/moex.module.ts index 9b20ab1..74e6327 100644 --- a/src/integrations/moex/moex.module.ts +++ b/src/integrations/moex/moex.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { MoexClient } from './moex.client'; @Module({ diff --git a/src/integrations/ncr/ncr-ratings.client.spec.ts b/src/integrations/ncr/ncr-ratings.client.spec.ts index 2ed3cbf..870da53 100644 --- a/src/integrations/ncr/ncr-ratings.client.spec.ts +++ b/src/integrations/ncr/ncr-ratings.client.spec.ts @@ -1,4 +1,5 @@ import { ServiceUnavailableException } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; const isin = 'RU000A10EW44'; const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => @@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => { ); }, ); + it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])( + 'accepts the public rating grammar: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value }); + }, + ); + it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])( + 'rejects invalid rating grades: %s', async (value) => { + jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value)))); + await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException); + }, + ); it('bounds outage retries then recovers without retaining a false missing rating', async () => { const fetchMock = jest .spyOn(global, 'fetch') diff --git a/src/integrations/ncr/ncr-ratings.client.ts b/src/integrations/ncr/ncr-ratings.client.ts index ff3e47d..16eea7a 100644 --- a/src/integrations/ncr/ncr-ratings.client.ts +++ b/src/integrations/ncr/ncr-ratings.client.ts @@ -11,11 +11,11 @@ const DATE_COLUMN = 5; const registryUrl = 'https://ratings.ru/ratings/issues/'; export type NcrRating = { agency: 'NCR'; - value: string | null; - status: 'active' | 'withdrawn'; date: string; - sourceUrl: string; observedAt: string; + sourceUrl: string; + status: 'active' | 'withdrawn'; + value: null | string; }; @Injectable() @@ -33,8 +33,9 @@ export class NcrRatingsClient { if (!pending) { pending = this.registry().catch(() => { // Bound outage traffic too; never present a failed download as no rating. - if (this.cache.get('registry') === pending) + if (this.cache.get('registry') === pending) { this.cache.set('registry', pending, { ttl: 60_000 }); + } throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }); this.cache.set('registry', pending); @@ -57,10 +58,13 @@ export class NcrRatingsClient { table.length !== 1 || headings.length !== COLUMN_COUNT || headings[2] !== 'Рейтинг' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[ISIN_COLUMN] !== 'ISIN' || + // eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants. headings[DATE_COLUMN] !== 'Дата' - ) + ) { throw new Error('NCR registry changed'); + } const observedAt = new Date().toISOString(); const result = new Map(); table.find('tbody tr').each((_, row) => { @@ -73,23 +77,26 @@ export class NcrRatingsClient { const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; if ( !withdrawn && - !/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( + !/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test( raw, ) - ) + ) { throw new Error('NCR rating changed'); + } const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( cells.eq(DATE_COLUMN).text().trim(), ); const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); - if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) + if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) { throw new Error('NCR identity missing'); + } const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; if ( !Number.isFinite(Date.parse(date)) || new Date(date).toISOString().slice(0, 10) !== date - ) + ) { throw new Error('NCR date invalid'); + } const rating: NcrRating = { agency: 'NCR', value: withdrawn ? null : raw, @@ -103,8 +110,9 @@ export class NcrRatingsClient { previous && previous.date === date && (previous.value !== rating.value || previous.status !== rating.status) - ) + ) { throw new Error('NCR rating ambiguous'); + } if (!previous || previous.date < date) result.set(isin, rating); }); if (!result.size) throw new Error('NCR registry empty'); diff --git a/src/integrations/ncr/ncr.module.ts b/src/integrations/ncr/ncr.module.ts index ae77a97..ee8bd4a 100644 --- a/src/integrations/ncr/ncr.module.ts +++ b/src/integrations/ncr/ncr.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { NcrRatingsClient } from './ncr-ratings.client'; @Module({ diff --git a/src/integrations/tbank/decimal.spec.ts b/src/integrations/tbank/decimal.spec.ts index 3cb5ea6..af463e1 100644 --- a/src/integrations/tbank/decimal.spec.ts +++ b/src/integrations/tbank/decimal.spec.ts @@ -21,3 +21,18 @@ describe('decimal', () => { ).toBe('1'); }); }); + +describe('decimal formatting', () => { + it.each([ + [120000n, 4, '12'], + [-120030n, 4, '-12.003'], + [1n, 4, '0.0001'], + [0n, 4, '0'], + ])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => { + expect(formatDecimal({ value, scale })).toBe(expected); + }); + it('preserves a long fraction with a trailing zero run', () => { + const significant = '1'.repeat(10_000); + expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant); + }); +}); diff --git a/src/integrations/tbank/decimal.ts b/src/integrations/tbank/decimal.ts index 06ec46e..f91faa0 100644 --- a/src/integrations/tbank/decimal.ts +++ b/src/integrations/tbank/decimal.ts @@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal'; const NANO = 1_000_000_000n; -export type ProtoDecimal = { units?: string | number; nano?: string | number }; +export type ProtoDecimal = { nano?: number | string; units?: number | string }; export function decimal(value: ProtoDecimal): Decimal { return { diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 9e66b73..ef67ad6 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -1,24 +1,26 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { - Prisma, TbankInstrumentLookup as MetadataLookupRow, TbankInstrument as MetadataRow, + Prisma, } from '../../../generated/prisma/client'; -import { PrismaService } from '../../prisma/prisma.service'; import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankInstrument } from './tbank.client'; + +import { PrismaService } from '../../prisma/prisma.service'; import { tbankSourceTtlMs } from './tbank-source.policy'; export type MetadataIdentity = { - instrumentUid?: string | null; - figi?: string | null; + figi?: null | string; + instrumentUid?: null | string; }; export type MetadataLookup = { - type: 'uid' | 'figi'; + data: null | TbankInstrument; id: string; - data: TbankInstrument | null; + type: 'figi' | 'uid'; }; const provider = 'tbank'; // Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. @@ -36,15 +38,16 @@ export class InstrumentMetadataRepository { async readMany(identities: MetadataIdentity[]) { const identifiers = identities.flatMap((identity) => [ - ...(identity.instrumentUid?.trim() - ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] - : []), - ...(identity.figi?.trim() - ? [{ identifierType: 'figi', identifier: identity.figi.trim() }] - : []), + ...(identity.instrumentUid?.trim() ? + [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : + []), + ...(identity.figi?.trim() ? + [{ identifierType: 'figi', identifier: identity.figi.trim() }] : + []), ]); - if (!identifiers.length) + if (!identifiers.length) { return new Map>(); + } return this.prisma.$transaction(async (tx) => { const lookups: MetadataLookupRow[] = []; for (const identifierType of ['uid', 'figi']) { @@ -59,7 +62,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < values.length; offset += metadataReadBatchSize - ) + ) { lookups.push( ...(await tx.tbankInstrumentLookup.findMany({ where: { @@ -71,6 +74,7 @@ export class InstrumentMetadataRepository { }, })), ); + } } const uids = [ ...new Set(lookups.flatMap((row) => row.matchedUids as string[])), @@ -80,7 +84,7 @@ export class InstrumentMetadataRepository { let offset = 0; offset < uids.length; offset += metadataReadBatchSize - ) + ) { instruments.push( ...(await tx.tbankInstrument.findMany({ where: { @@ -89,6 +93,7 @@ export class InstrumentMetadataRepository { }, })), ); + } const byIdentifier = new Map( lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), ); @@ -103,7 +108,7 @@ export class InstrumentMetadataRepository { } async reserve( - attemptOwner: { userId: string; revision: string }, + attemptOwner: { revision: string; userId: string }, identity: MetadataIdentity, runId: string, active: ReadonlySet, @@ -125,8 +130,9 @@ export class InstrumentMetadataRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -183,12 +189,13 @@ export class InstrumentMetadataRepository { ['uid', uid], ['figi', figi], ] as const) { - if (id && uid && !aliases.has(`${type}:${id}`)) + if (id && uid && !aliases.has(`${type}:${id}`)) { aliases.set(`${type}:${id}`, { type, id, data: { ...result.data, uid }, }); + } } } for (const result of aliases.values()) { @@ -196,8 +203,9 @@ export class InstrumentMetadataRepository { const uid = instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); // FIGI is an alias, never a fabricated provider UID. - if (instrument && !uid) + if (instrument && !uid) { throw new Error('Metadata response has no instrument UID'); + } if (instrument && uid) { const key = { provider, uid }; const previous = await tx.tbankInstrument.findUnique({ @@ -220,9 +228,9 @@ export class InstrumentMetadataRepository { update: { ...data, dataRevision: { - increment: isDeepStrictEqual(previous?.metadata, payload) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.metadata, payload) ? + 0 : + 1, }, }, }); @@ -243,9 +251,9 @@ export class InstrumentMetadataRepository { matchedUids, fetchedAt: now, dataRevision: { - increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) - ? 0 - : 1, + increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? + 0 : + 1, }, }, }); @@ -266,7 +274,7 @@ export class InstrumentMetadataRepository { errorCode: success ? null : 'TBANK_UNAVAILABLE', }, }); - if (success) + if (success) { await this.prisma.tbankSyncWork.updateMany({ where: { ...attempt, @@ -275,6 +283,7 @@ export class InstrumentMetadataRepository { }, data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, }); + } } } @@ -283,7 +292,7 @@ function selectMetadata( lookups: ReadonlyMap, instruments: ReadonlyMap, ) { - const resolve = (type: string, id: string | null | undefined) => { + const resolve = (type: string, id: null | string | undefined) => { const lookup = lookups.get(`${type}:${id?.trim()}`); const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const instrument = uid ? instruments.get(uid) : undefined; @@ -307,6 +316,7 @@ function selectMetadata( complete, fresh: complete && + // eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard. fetchedAt !== null && Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, fetchedAt, diff --git a/src/integrations/tbank/instrument-metadata.service.ts b/src/integrations/tbank/instrument-metadata.service.ts index 42fdf9c..ee3c4a9 100644 --- a/src/integrations/tbank/instrument-metadata.service.ts +++ b/src/integrations/tbank/instrument-metadata.service.ts @@ -1,23 +1,24 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { + InstrumentMetadataRepository, + type MetadataIdentity, + metadataKey, + type MetadataLookup, +} from './instrument-metadata.repository'; import { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; import { TbankClient, type TbankInstrument } from './tbank.client'; -import { - InstrumentMetadataRepository, - metadataKey, - type MetadataIdentity, - type MetadataLookup, -} from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository'; type Producer = { - identity: MetadataIdentity; attempt: TbankSyncAttempt; + identity: MetadataIdentity; observers: TbankSyncAttempt[]; }; @Injectable() @@ -49,8 +50,9 @@ export class InstrumentMetadataService { const connection = await this.access.local(userId); if (revision && connection.revision !== revision) return null; const data = (await this.savedMany([identity])).get(metadataKey(identity)); - if (!observe && !data?.fresh) + if (!observe && !data?.fresh) { await this.request(userId, connection.revision, identity); + } return { ...data, work: await this.repository.work(userId, connection.revision, identity), @@ -119,7 +121,7 @@ export class InstrumentMetadataService { let useful = false; let uidFailed = false; if (identity.instrumentUid) { - let data: TbankInstrument | null | undefined; + let data: null | TbankInstrument | undefined; try { data = await this.tbank.getInstrumentByUid( token, @@ -130,29 +132,37 @@ export class InstrumentMetadataService { !identity.figi || (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') - ) + ) { throw error; + } uidFailed = true; } - if (data?.uid && data.uid !== identity.instrumentUid) + if (data?.uid && data.uid !== identity.instrumentUid) { throw new Error('Metadata UID mismatch'); - if (data !== undefined) + } + if (data !== undefined) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); - if (!useful && data && !identity.figi) + if (!useful && data && !identity.figi) { throw new Error('Incomplete metadata'); + } } if (!useful && identity.figi) { const data = await this.tbank.getInstrumentByFigi( token, identity.figi, ); - if (data?.figi && data.figi !== identity.figi) + if (data?.figi && data.figi !== identity.figi) { throw new Error('Metadata FIGI mismatch'); - if (data && !data.name?.trim() && !data.ticker?.trim()) + } + if (data && !data.name?.trim() && !data.ticker?.trim()) { throw new Error('Incomplete metadata'); - if (data === undefined || (uidFailed && !data)) + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads. + if (data === undefined || (uidFailed && !data)) { throw new Error('Metadata source unavailable'); + } results.push({ type: 'figi', id: identity.figi, data }); // A successful FIGI response identifies its canonical UID too; // a failed UID RPC is never persisted as a negative lookup. @@ -160,8 +170,9 @@ export class InstrumentMetadataService { uidFailed && data?.uid === identity.instrumentUid && identity.instrumentUid - ) + ) { results.push({ type: 'uid', id: identity.instrumentUid, data }); + } } if ( !results.length || @@ -170,8 +181,9 @@ export class InstrumentMetadataService { (result) => result.data?.name?.trim() || result.data?.ticker?.trim(), )) - ) + ) { throw new Error('Incomplete metadata'); + } success = await this.repository.publish( attempt, results, @@ -179,12 +191,13 @@ export class InstrumentMetadataService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'instrument-metadata.failed', { code: 'TBANK_UNAVAILABLE', }); + } } // Remove the producer before awaiting finalization: a new request cannot // attach to a producer whose observer list has already been drained. diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 48be690..613fa1e 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -1,4 +1,3 @@ -import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { BadRequestException, ConflictException, @@ -6,16 +5,19 @@ import { NotFoundException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../prisma/prisma.service'; -import { supportedTbankAccounts } from './tbank-account.policy'; -import { TbankTokenService } from './tbank-token.service'; + import type { TbankAccount } from './tbank.client'; +import { PrismaService } from '../../prisma/prisma.service'; +import { supportedTbankAccounts } from './tbank-account.policy'; +import { tbankSourceNeedsRefresh } from './tbank-source.policy'; +import { TbankTokenService } from './tbank-token.service'; + export type TbankSyncAttempt = { - userId: string; - revision: string; - id: string; attemptId: string; + id: string; + revision: string; + userId: string; }; @Injectable() @@ -48,8 +50,9 @@ export class TbankAccessService { connection.membershipAt, new Date(), ) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: 'membership' }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -58,8 +61,9 @@ export class TbankAccessService { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId, @@ -100,7 +104,7 @@ export class TbankAccessService { return result.count === 1; } - async tokenForAttempt(attempt: TbankSyncAttempt): Promise { + async tokenForAttempt(attempt: TbankSyncAttempt): Promise { const work = await this.prisma.tbankSyncWork.findFirst({ where: { ...attempt, @@ -119,8 +123,9 @@ export class TbankAccessService { completeWork = false, ): Promise { const supported = supportedTbankAccounts(accounts); - if (new Set(supported.map(({ id }) => id)).size !== supported.length) + if (new Set(supported.map(({ id }) => id)).size !== supported.length) { throw new Error('Duplicate T-Bank account identity'); + } return this.prisma.$transaction(async (tx) => { // Acquire the write fence before reading membership. Replacement and // attempt supersession cannot interleave with the publication below. @@ -146,6 +151,7 @@ export class TbankAccessService { const changed = previous.length !== next.length || previous.some((account, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration. const incoming = next[index]; return ( account.accountId !== incoming.accountId || @@ -181,11 +187,12 @@ export class TbankAccessService { membershipRevision: { increment: changed ? 1 : 0 }, }, }); - if (completeWork) + if (completeWork) { await tx.tbankSyncWork.update({ where: { id: attempt.id }, data: { state: 'complete', progress: { accounts: supported.length } }, }); + } return true; }); } @@ -224,17 +231,20 @@ export class TbankAccessService { accounts: { orderBy: { upstreamOrder: 'asc' } }, }, }); - if (!connection) + if (!connection) { throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); - if (connection.invalidToken) + } + if (connection.invalidToken) { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } if ( connection.membershipAt && accountId && accountId !== 'all' && !connection.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } return connection; } } diff --git a/src/integrations/tbank/tbank-account.policy.ts b/src/integrations/tbank/tbank-account.policy.ts index 0589da5..8ce7264 100644 --- a/src/integrations/tbank/tbank-account.policy.ts +++ b/src/integrations/tbank/tbank-account.policy.ts @@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes; export type SupportedTbankAccount = { id: string; name: string; + openedOn: null | string; type: (typeof accountTypes)[AccountType]['type']; - openedOn: string | null; }; export function supportedTbankAccounts( @@ -39,7 +39,7 @@ export function supportedTbankAccounts( }); } -function openedOn(seconds: string | number | undefined): string | null { +function openedOn(seconds: number | string | undefined): null | string { if (seconds === undefined) return null; const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); if (Number.isNaN(date.valueOf())) return null; diff --git a/src/integrations/tbank/tbank-market.types.ts b/src/integrations/tbank/tbank-market.types.ts index 825953a..e931ccb 100644 --- a/src/integrations/tbank/tbank-market.types.ts +++ b/src/integrations/tbank/tbank-market.types.ts @@ -6,23 +6,23 @@ import type { } from './tbank.client'; export type TbankShare = TbankInstrument & { - uid?: string; - figi?: string; + apiTradeAvailableFlag?: boolean; assetUid?: string; classCode?: string; - realExchange?: string; - apiTradeAvailableFlag?: boolean; currency?: string; - shareType?: string; - instrumentKind?: string; - sector?: string; - lot?: number; + figi?: string; first_1dayCandleDate?: TbankTimestamp; + instrumentKind?: string; + lot?: number; + realExchange?: string; + sector?: string; + shareType?: string; + uid?: string; }; export type TbankCandle = { - time?: TbankTimestamp; close?: TbankDecimal; isComplete?: boolean; + time?: TbankTimestamp; }; export type TbankLastPrice = { instrumentUid?: string; @@ -32,38 +32,38 @@ export type TbankLastPrice = { export type TbankFundamentals = { assetUid?: string; currency?: string; + ebitdaTtm?: number; marketCapitalization?: number; + netDebtToEbitda?: number; + netIncomeTtm?: number; peRatioTtm?: number; priceToBookTtm?: number; revenueTtm?: number; - netIncomeTtm?: number; - ebitdaTtm?: number; roe?: number; totalDebtMrq?: number; - netDebtToEbitda?: number; }; export type TbankBond = Omit & { - nominal?: TbankMoney; - initialNominal?: TbankMoney; aciValue?: TbankMoney; - maturityDate?: TbankTimestamp; + amortizationFlag?: boolean; + bondType?: string; callDate?: TbankTimestamp; couponQuantityPerYear?: number; floatingCouponFlag?: boolean; + initialNominal?: TbankMoney; + maturityDate?: TbankTimestamp; + nominal?: TbankMoney; perpetualFlag?: boolean; - amortizationFlag?: boolean; - subordinatedFlag?: boolean; - bondType?: string; riskLevel?: string; + subordinatedFlag?: boolean; }; export type TbankCoupon = { - couponNumber?: string; couponDate?: TbankTimestamp; + couponEndDate?: TbankTimestamp; + couponNumber?: string; + couponPeriod?: number; + couponStartDate?: TbankTimestamp; + couponType?: string; fixDate?: TbankTimestamp; payOneBond?: TbankMoney; - couponType?: string; - couponStartDate?: TbankTimestamp; - couponEndDate?: TbankTimestamp; - couponPeriod?: number; }; diff --git a/src/integrations/tbank/tbank-membership.service.ts b/src/integrations/tbank/tbank-membership.service.ts index 0522c2b..784c3cc 100644 --- a/src/integrations/tbank/tbank-membership.service.ts +++ b/src/integrations/tbank/tbank-membership.service.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { TbankAccessService, diff --git a/src/integrations/tbank/tbank-source.policy.spec.ts b/src/integrations/tbank/tbank-source.policy.spec.ts index 8952a4f..03307b9 100644 --- a/src/integrations/tbank/tbank-source.policy.spec.ts +++ b/src/integrations/tbank/tbank-source.policy.spec.ts @@ -8,6 +8,7 @@ describe('request-driven source freshness', () => { '%s expires at its boundary without changing source time', (source) => { const fetchedAt = new Date('2026-09-01Z'); + // eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table. const ttl = tbankSourceTtlMs[source]; expect( tbankSourceNeedsRefresh( diff --git a/src/integrations/tbank/tbank-source.policy.ts b/src/integrations/tbank/tbank-source.policy.ts index 39e3f54..ca2d39a 100644 --- a/src/integrations/tbank/tbank-source.policy.ts +++ b/src/integrations/tbank/tbank-source.policy.ts @@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh( return ( force || !fetchedAt || + // eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union. now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source] ); } diff --git a/src/integrations/tbank/tbank-token.service.ts b/src/integrations/tbank/tbank-token.service.ts index 2e1a9ed..e029e07 100644 --- a/src/integrations/tbank/tbank-token.service.ts +++ b/src/integrations/tbank/tbank-token.service.ts @@ -1,5 +1,6 @@ import { BadRequestException, Injectable } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; + import { Environment } from '../../config/env.validation'; import { TbankCrypto } from './tbank.crypto'; @@ -25,8 +26,9 @@ export class TbankTokenService { try { return await operation(); } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); + } throw error; } } diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index d260155..1f297d5 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -1,9 +1,12 @@ // Public cross-module surface. Nest providers are exported by the matching module. export { InstrumentMetadataService } from './instrument-metadata.service'; +export { metadataKey } from './instrument-metadata.service'; +export type { MetadataIdentity } from './instrument-metadata.service'; export { TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; +export { supportedTbankAccounts } from './tbank-account.policy'; export { type TbankBond, type TbankCandle, @@ -12,8 +15,15 @@ export { type TbankLastPrice, type TbankShare, } from './tbank-market.types'; + +export { TbankMembershipService } from './tbank-membership.service'; + +export { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from './tbank-source.policy'; + export { TbankTokenService } from './tbank-token.service'; -export { supportedTbankAccounts } from './tbank-account.policy'; export { TbankClient, type TbankDividend, @@ -25,13 +35,3 @@ export { type TbankPositions, type TbankTimestamp, } from './tbank.client'; - -export { TbankMembershipService } from './tbank-membership.service'; - -export { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from './tbank-source.policy'; - -export { metadataKey } from './instrument-metadata.service'; -export type { MetadataIdentity } from './instrument-metadata.service'; diff --git a/src/integrations/tbank/tbank.client.spec.ts b/src/integrations/tbank/tbank.client.spec.ts index 22456bc..525ab85 100644 --- a/src/integrations/tbank/tbank.client.spec.ts +++ b/src/integrations/tbank/tbank.client.spec.ts @@ -1,5 +1,6 @@ import { status } from '@grpc/grpc-js'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; + import { TbankClient } from './tbank.client'; describe('TbankClient operations', () => { @@ -57,6 +58,7 @@ describe('TbankClient operations', () => { const client = Object.create(TbankClient.prototype) as TbankClient; const call = jest.fn().mockResolvedValue({}); (client as unknown as { call: jest.Mock }).call = call; + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); expect(call).toHaveBeenCalledWith('token', undefined, method, { instrumentId: 'uid', @@ -111,11 +113,10 @@ describe('TbankClient operations', () => { ok: true, }); const failed = invoke('token', {}, 'getPortfolio', {}); - const rejected = expect(failed).rejects.toEqual( - expect.any(ServiceUnavailableException), - ); - await jest.advanceTimersByTimeAsync(1000); - await rejected; + await Promise.all([ + expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)), + jest.advanceTimersByTimeAsync(1000), + ]); expect(logger.event).toHaveBeenCalledWith( 'debug', @@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => { const instrument = { name: 'Облигация', ticker: 'BOND' }; let client: TbankClient; let requestRpc: jest.Mock; - let queue: { size: number; run: jest.Mock }; + let queue: { run: jest.Mock; size: number }; let logger: { event: jest.Mock]>; }; @@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying a saturated queue after three admission attempts', async () => { queue.size = 50; - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).not.toHaveBeenCalled(); expect(logger.event).toHaveBeenCalledTimes(3); @@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => { it('stops retrying an unavailable upstream after three RPCs', async () => { requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); - const result = expect( - client.getInstrumentByUid('token', 'uid'), - ).rejects.toBeInstanceOf(ServiceUnavailableException); - - await jest.advanceTimersByTimeAsync(6000); - await result; + await Promise.all([ + expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), + jest.advanceTimersByTimeAsync(6000), + ]); expect(queue.run).toHaveBeenCalledTimes(3); expect(jest.getTimerCount()).toBe(0); @@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => { async (method) => { requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); + // eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table. await expect(client[method]('token', 'instrument')).resolves.toBeNull(); expect(requestRpc).toHaveBeenCalledTimes(1); expect(jest.getTimerCount()).toBe(0); diff --git a/src/integrations/tbank/tbank.client.ts b/src/integrations/tbank/tbank.client.ts index 19376aa..902733f 100644 --- a/src/integrations/tbank/tbank.client.ts +++ b/src/integrations/tbank/tbank.client.ts @@ -1,4 +1,3 @@ -import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ChannelCredentials, Client, @@ -17,8 +16,6 @@ import { import { ConfigService } from '@nestjs/config'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { StructuredLogger } from '../../common/logging/structured-logger.service'; -import { Environment } from '../../config/env.validation'; import type { TbankBond, @@ -29,6 +26,10 @@ import type { TbankShare, } from './tbank-market.types'; +import { StructuredLogger } from '../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../common/time'; +import { Environment } from '../../config/env.validation'; + const RATE_WINDOW_MS = 1_000; const METADATA_MAX_ATTEMPTS = 3; const RPC_MAX_ATTEMPTS = 2; @@ -42,78 +43,78 @@ type Unary = ( request: Request, metadata: Metadata, options: { deadline: Date }, - callback: (error: ServiceError | null, response: Response) => void, + callback: (error: null | ServiceError, response: Response) => void, ) => ClientUnaryCall; type TbankClientService = Client & Record>; -export type TbankDecimal = { units?: string | number; nano?: string | number }; +export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankAccount = { id: string; - type: string; name: string; - openedDate?: { seconds?: string | number }; + openedDate?: { seconds?: number | string }; + type: string; }; export type TbankPortfolio = { positions?: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - ticker?: string; - quantity?: TbankDecimal; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: TbankDecimal; + ticker?: string; }[]; }; export type TbankPositions = { money?: TbankMoney[] }; export type TbankInstrument = { - uid?: string; + brand?: { logoName?: null | string }; figi?: string; instrumentType?: string; - ticker?: string; - name?: string; isin?: string; - brand?: { logoName?: string | null }; + name?: string; + ticker?: string; + uid?: string; }; export type TbankDividend = { dividendNet?: TbankMoney; - recordDate?: TbankTimestamp; - paymentDate?: TbankTimestamp; - lastBuyDate?: TbankTimestamp; dividendType?: string; + lastBuyDate?: TbankTimestamp; + paymentDate?: TbankTimestamp; + recordDate?: TbankTimestamp; }; -export type TbankTimestamp = { seconds?: string | number; nanos?: number }; +export type TbankTimestamp = { nanos?: number; seconds?: number | string }; export type TbankOperationItem = { - state?: string; + accruedInt?: TbankMoney; + commission?: TbankMoney; cursor?: string; - id?: string; - name?: string; date?: TbankTimestamp; - type?: string; description?: string; figi?: string; - instrumentUid?: string; + id?: string; instrumentType?: string; - ticker?: string; + instrumentUid?: string; + name?: string; payment?: TbankMoney; price?: TbankMoney; - commission?: TbankMoney; - accruedInt?: TbankMoney; - quantity?: string | number; - quantityDone?: string | number; + quantity?: number | string; + quantityDone?: number | string; + state?: string; + ticker?: string; + type?: string; }; export type TbankOperationsByCursorRequest = { accountId: string; - instrumentId?: string; - from?: TbankTimestamp; - to?: TbankTimestamp; cursor?: string; + from?: TbankTimestamp; + instrumentId?: string; limit?: number; state: 'OPERATION_STATE_EXECUTED'; + to?: TbankTimestamp; }; export type TbankOperationsByCursorResponse = { hasNext?: boolean; - nextCursor?: string; items?: TbankOperationItem[]; + nextCursor?: string; }; type TbankNamespace = { tinkoff: { @@ -197,12 +198,14 @@ export class TbankClient { loadPackageDefinition(definition) as unknown as TbankNamespace ).tinkoff.public.invest.api.contract.v1; const caPath = config.get('TBANK_CA_CERT_PATH'); - const credentials = caPath - ? ChannelCredentials.createSsl(readFileSync(caPath)) - : ChannelCredentials.createSsl(); + const credentials = caPath ? + // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. + ChannelCredentials.createSsl(readFileSync(caPath)) : + ChannelCredentials.createSsl(); const endpoint = config.getOrThrow('TBANK_GRPC_ENDPOINT'); const create = (name: string) => new ( + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. services[name] as new ( address: string, credentials: ChannelCredentials, @@ -268,14 +271,14 @@ export class TbankClient { getInstrumentByFigi( token: string, figi: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); } getInstrumentByUid( token: string, instrumentUid: string, - ): Promise { + ): Promise { return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); } @@ -320,7 +323,8 @@ export class TbankClient { }, ).then((response) => response.instruments ?? []); } - getBond(token: string, uid: string): Promise { + + getBond(token: string, uid: string): Promise { return this.call<{ instrument?: TbankBond }>( token, this.sparseInstruments, @@ -331,6 +335,7 @@ export class TbankClient { }, ).then((response) => response.instrument ?? null); } + getBondCoupons( token: string, uid: string, @@ -349,7 +354,7 @@ export class TbankClient { ).then((response) => response.events ?? []); } - getShare(token: string, uid: string): Promise { + getShare(token: string, uid: string): Promise { return this.call<{ instrument?: TbankShare }>( token, this.instruments, @@ -361,7 +366,7 @@ export class TbankClient { ).then((response) => response.instrument ?? null); } - getAssetDescription(token: string, assetUid: string): Promise { + getAssetDescription(token: string, assetUid: string): Promise { return this.call<{ asset?: { description?: string } }>( token, this.instruments, @@ -375,7 +380,7 @@ export class TbankClient { getFundamentals( token: string, assetUid: string, - ): Promise { + ): Promise { return this.call<{ fundamentals?: TbankFundamentals[] }>( token, this.sparseInstruments, @@ -399,13 +404,13 @@ export class TbankClient { ).then((response) => response.lastPrices ?? []); } - getLastPrice(token: string, uid: string): Promise { + getLastPrice(token: string, uid: string): Promise { return this.getLastPrices(token, [uid]).then( (prices) => prices.find((item) => item.instrumentUid === uid) ?? null, ); } - getTradingStatus(token: string, uid: string): Promise { + getTradingStatus(token: string, uid: string): Promise { return this.call<{ tradingStatus?: string }>( token, this.marketData, @@ -442,9 +447,9 @@ export class TbankClient { private async getInstrument( token: string, - idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', + idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', id: string, - ): Promise { + ): Promise { try { const response = await this.call<{ instrument?: TbankInstrument }>( token, @@ -471,8 +476,9 @@ export class TbankClient { const startedAt = performance.now(); const queueFull = this.queue.size >= MAX_QUEUE_SIZE; try { - if (queueFull) + if (queueFull) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } const response = await this.queue.run(() => this.request(token, client, method, request), ); @@ -484,15 +490,15 @@ export class TbankClient { return response; } catch (error) { const grpcCode = (error as ServiceError).code; - const failureContext = queueFull - ? { code: 'TBANK_UNAVAILABLE' } - : { grpcCode }; - const retryable = queueFull - ? metadataCall - : [ - status.UNAVAILABLE, + const failureContext = queueFull ? + { code: 'TBANK_UNAVAILABLE' } : + { grpcCode }; + const retryable = queueFull ? + metadataCall : + [ status.DEADLINE_EXCEEDED, status.RESOURCE_EXHAUSTED, + status.UNAVAILABLE, ].includes(grpcCode); if (retryable && attempts < maxAttempts) { this.logger.event('warn', 'tbank.rpc.retry', { @@ -504,9 +510,9 @@ export class TbankClient { await new Promise((resolve) => setTimeout( resolve, - metadataCall - ? attempts * METADATA_RETRY_DELAY_MS - : RPC_RETRY_DELAY_MS, + metadataCall ? + attempts * METADATA_RETRY_DELAY_MS : + RPC_RETRY_DELAY_MS, ), ); continue; @@ -518,15 +524,17 @@ export class TbankClient { durationMs: Math.round(performance.now() - startedAt), }); if ( - [status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) - ) + [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) + ) { // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. throw new Error('INVALID_TBANK_TOKEN'); + } if ( grpcCode === status.NOT_FOUND && (method === 'shareBy' || metadataCall) - ) + ) { throw new NotFoundException(); + } throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); } } @@ -542,6 +550,7 @@ export class TbankClient { return new Promise((resolve, reject) => { const metadata = new Metadata(); metadata.set('Authorization', `Bearer ${token}`); + // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. client[method]( request as never, metadata, diff --git a/src/integrations/tbank/tbank.crypto.ts b/src/integrations/tbank/tbank.crypto.ts index 6eebe25..a40d081 100644 --- a/src/integrations/tbank/tbank.crypto.ts +++ b/src/integrations/tbank/tbank.crypto.ts @@ -11,8 +11,9 @@ export class TbankCrypto { static fromBase64(key: string): TbankCrypto { const decoded = Buffer.from(key, 'base64'); - if (decoded.length !== KEY_SIZE_BYTES) + if (decoded.length !== KEY_SIZE_BYTES) { throw new Error('Invalid token encryption key'); + } return new TbankCrypto(decoded); } diff --git a/src/integrations/tbank/tbank.module.ts b/src/integrations/tbank/tbank.module.ts index fa7eed8..3fff10d 100644 --- a/src/integrations/tbank/tbank.module.ts +++ b/src/integrations/tbank/tbank.module.ts @@ -1,8 +1,9 @@ import { Module } from '@nestjs/common'; -import { TbankMembershipService } from './tbank-membership.service'; + import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataService } from './instrument-metadata.service'; import { TbankAccessService } from './tbank-access.service'; +import { TbankMembershipService } from './tbank-membership.service'; import { TbankTokenService } from './tbank-token.service'; import { TbankClient } from './tbank.client'; diff --git a/src/main.ts b/src/main.ts index 61c4511..1ea1611 100644 --- a/src/main.ts +++ b/src/main.ts @@ -3,18 +3,19 @@ import { INestApplication, ValidationPipe, } from '@nestjs/common'; -import { NestFactory } from '@nestjs/core'; -import cookieParser from 'cookie-parser'; import { ConfigService } from '@nestjs/config'; +import { NestFactory } from '@nestjs/core'; import { SwaggerModule } from '@nestjs/swagger'; -import { API_PREFIX, createOpenApiDocument } from './openapi'; +import cookieParser from 'cookie-parser'; import { Logger } from 'nestjs-pino'; + import { AppModule } from './app.module'; import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware'; import { StructuredLogger } from './common/logging/structured-logger.service'; import { Environment } from './config/env.validation'; import { shouldExposeSwagger } from './config/swagger.config'; +import { API_PREFIX, createOpenApiDocument } from './openapi'; export function configureApp(app: INestApplication): void { const config = app.get>(ConfigService); @@ -32,6 +33,7 @@ export function configureApp(app: INestApplication): void { app.use(requestLogging.use.bind(requestLogging)); app.useGlobalPipes( new ValidationPipe({ + forbidUnknownValues: true, transform: true, whitelist: true, validationError: { target: false, value: false }, diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index df2fe1f..3e04a6d 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -114,8 +114,8 @@ describe('AuthService', () => { where, data, }: { - where: { id?: string; refreshTokenHash?: string }; data: { refreshTokenHash: string }; + where: { id?: string; refreshTokenHash?: string }; }) => { if (where.id === session.id && where.refreshTokenHash === currentHash) { currentHash = data.refreshTokenHash; diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 61babc2..b2528c8 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -6,6 +6,7 @@ import { import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { createHash, randomUUID } from 'crypto'; + import { Session, User } from '../../../../generated/prisma/client'; import { PrismaService } from '../../../prisma/prisma.service'; import { AuthUser, RefreshPayload } from './auth.types'; @@ -16,6 +17,7 @@ const REFRESH_TTL_MS = 604_800_000; const ACCESS_TTL = '15m'; const REFRESH_TTL = '7d'; const DUMMY_PASSWORD_HASH = + // eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential. '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; @Injectable() @@ -28,12 +30,13 @@ export class AuthService { async register( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const existing = await this.prisma.user.findUnique({ where: { email }, }); - if (existing) + if (existing) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } let user: User; try { @@ -45,8 +48,9 @@ export class AuthService { }, }); } catch (error) { - if (isUniqueConstraintError(error)) + if (isUniqueConstraintError(error)) { throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); + } throw error; } return this.createSession(user); @@ -55,7 +59,7 @@ export class AuthService { async login( email: string, password: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const user = await this.prisma.user.findUnique({ where: { email }, }); @@ -63,14 +67,15 @@ export class AuthService { password, user?.passwordHash ?? DUMMY_PASSWORD_HASH, ); - if (!user || !valid) + if (!user || !valid) { throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); + } return this.createSession(user); } async refresh( token: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { @@ -79,8 +84,9 @@ export class AuthService { } catch { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (payload.type !== 'refresh') + if (payload.type !== 'refresh') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } const session = await this.prisma.session.findUnique({ where: { id: payload.sid }, include: { user: true }, @@ -91,12 +97,14 @@ export class AuthService { session.revokedAt || session.expiresAt <= new Date() ) { - if (session && !session.revokedAt) + if (session && !session.revokedAt) { await this.prisma.session.delete({ where: { id: session.id } }); + } throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); } - if (session.refreshTokenHash !== this.hash(token)) + if (session.refreshTokenHash !== this.hash(token)) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return this.rotateSession(session, session.refreshTokenHash); } @@ -138,7 +146,7 @@ export class AuthService { private async createSession( user: User, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const session = await this.prisma.session.create({ data: { id: randomUUID(), @@ -153,7 +161,7 @@ export class AuthService { private async rotateSession( session: Session & { user: User }, expectedRefreshTokenHash?: string, - ): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { + ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { const refreshToken = await this.jwt.signAsync( { sub: session.userId, @@ -172,8 +180,9 @@ export class AuthService { where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, data, }); - if (updated.count !== 1) + if (updated.count !== 1) { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } } else { await this.prisma.session.update({ where: { id: session.id }, data }); } diff --git a/src/modules/auth/application/auth.types.ts b/src/modules/auth/application/auth.types.ts index cbeba59..6e5348e 100644 --- a/src/modules/auth/application/auth.types.ts +++ b/src/modules/auth/application/auth.types.ts @@ -1,17 +1,17 @@ export interface AuthUser { - id: string; email: string; + id: string; } export interface AccessPayload { - sub: string; sid: string; + sub: string; type: 'access'; } export interface RefreshPayload { - sub: string; - sid: string; - type: 'refresh'; jti: string; + sid: string; + sub: string; + type: 'refresh'; } diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 9fa5687..1d93f41 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -2,11 +2,12 @@ import { Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; import { PassportModule } from '@nestjs/passport'; + import { Environment } from '../../config/env.validation'; -import { AuthController } from './presentation/auth.controller'; import { AuthService } from './application/auth.service'; -import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtStrategy } from './infrastructure/jwt.strategy'; +import { AuthController } from './presentation/auth.controller'; +import { JwtAuthGuard } from './presentation/jwt-auth.guard'; @Module({ imports: [ diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index 1f97b0e..96110de 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; + import { Environment } from '../../../config/env.validation'; import { AccessPayload } from '../application/auth.types'; @@ -17,8 +18,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { } validate(payload: AccessPayload): AccessPayload { - if (payload.type !== 'access') + if (payload.type !== 'access') { throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); + } return payload; } } diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 45a35dc..c169f0e 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -1,3 +1,5 @@ +import type { Request, Response } from 'express'; + import { Body, Controller, @@ -17,11 +19,11 @@ import { ApiProperty, ApiTags, } from '@nestjs/swagger'; -import type { Request, Response } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; -import { CredentialsDto } from './auth.dto'; import { AuthService } from '../application/auth.service'; import { AccessPayload } from '../application/auth.types'; +import { CredentialsDto } from './auth.dto'; import { JwtAuthGuard } from './jwt-auth.guard'; const REFRESH_COOKIE = 'refresh_token'; @@ -115,9 +117,9 @@ export class AuthController { private respond( response: Response, result: { - user: { id: string; email: string }; accessToken: string; refreshToken: string; + user: { email: string; id: string }; }, ) { response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); @@ -129,6 +131,7 @@ export class AuthController { } private refreshToken(request: Request): string | undefined { + // eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below. const token: unknown = request.cookies?.[REFRESH_COOKIE]; return typeof token === 'string' ? token : undefined; } diff --git a/src/modules/auth/presentation/auth.dto.ts b/src/modules/auth/presentation/auth.dto.ts index b2e25ec..62fe648 100644 --- a/src/modules/auth/presentation/auth.dto.ts +++ b/src/modules/auth/presentation/auth.dto.ts @@ -28,9 +28,10 @@ class EmailConstraint { character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(0) === ASCII_DELETE, ) && - value.split('@').every(Boolean) + value.split('@').every(Boolean) ); } + defaultMessage(): string { return 'must be a valid email address'; } @@ -44,6 +45,7 @@ class PasswordBytesConstraint { Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES ); } + defaultMessage(): string { return 'must not exceed 72 UTF-8 bytes'; } diff --git a/src/modules/auth/presentation/jwt-auth.guard.ts b/src/modules/auth/presentation/jwt-auth.guard.ts index 8f0f625..7056b18 100644 --- a/src/modules/auth/presentation/jwt-auth.guard.ts +++ b/src/modules/auth/presentation/jwt-auth.guard.ts @@ -1,5 +1,6 @@ import { ExecutionContext, Injectable } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; + import { RequestContextService } from '../../../common/logging/request-context.service'; import { AccessPayload } from '../application/auth.types'; @@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') { const request = context .switchToHttp() .getRequest<{ user?: AccessPayload }>(); - if (request.user?.sub) + if (request.user?.sub) { this.requestContext.set({ userId: request.user.sub }); + } return Boolean(allowed); } } diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index e62f380..e0f3440 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -1,21 +1,22 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; import { BadRequestException, ConflictException, NotFoundException, } from '@nestjs/common'; import { createHash } from 'node:crypto'; + +import { + MAX_CARD_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; +import { parseRubMoney } from '../domain/rub-money'; import { bankPaymentSystems, BankPeriodQueryDto, CreateBankCardDto, CreateBankOperationDto, } from '../presentation/bank-card.dto'; -import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations'; -import { parseRubMoney } from '../domain/rub-money'; export const BankErrorCode = { InvalidInput: 'BANK_INVALID_INPUT', @@ -54,8 +55,9 @@ export function periodBounds(query: BankPeriodQueryDto) { !isCalendarDate(query.from) || !isCalendarDate(query.to) || query.from > query.to - ) + ) { return fail(); + } return { from: query.from, to: query.to }; } export function conflict( @@ -93,8 +95,9 @@ export function currentDate(value: string) { if (value > moscowToday()) fail(BankErrorCode.InvalidDate); } export function name(value: string, max: number) { - if (typeof value !== 'string' || !value.trim() || value.trim().length > max) + if (typeof value !== 'string' || !value.trim() || value.trim().length > max) { fail(); + } return value.trim(); } export function cardInput(dto: CreateBankCardDto) { @@ -104,8 +107,9 @@ export function cardInput(dto: CreateBankCardDto) { if ( dto.paymentSystem != null && !bankPaymentSystems.some((system) => system === dto.paymentSystem) - ) + ) { fail(); + } return { ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), name: name(dto.name, MAX_CARD_NAME_LENGTH), @@ -120,8 +124,9 @@ export function operationInput(dto: CreateBankOperationDto) { dto.description != null && (typeof dto.description !== 'string' || dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) - ) + ) { fail(); + } return { kind: dto.kind, cardId: dto.cardId, diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 356c5a0..1f5de0c 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,24 +1,24 @@ import { Injectable } from '@nestjs/common'; + import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { bankBalance } from '../domain/bank-card.calculations'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardDto, CreateBankCardDto, UpdateBankCardDto, } from '../presentation/bank-card.dto'; -import { bankBalance } from '../domain/bank-card.calculations'; -import { rubMoney } from '../domain/rub-money'; - import { - cardInput, BankErrorCode, + cardInput, conflict, currentDate, fail, fingerprint, missing, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { @@ -26,6 +26,7 @@ export class BankCardsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async cardView(tx: Tx, card: BankCard): Promise { const rows = await tx.bankCardOperation.findMany({ where: { @@ -47,6 +48,7 @@ export class BankCardsService { archivedAt: card.archivedAt?.toISOString() ?? null, }; } + list(userId: string, includeArchived = false) { return this.prisma.$transaction(async (tx) => Promise.all( @@ -59,11 +61,13 @@ export class BankCardsService { ), ); } + get(userId: string, id: string) { return this.prisma.$transaction(async (tx) => this.cardView(tx, await this.repository.card(tx, userId, id)), ); } + createCard(userId: string, dto: CreateBankCardDto) { const input = cardInput(dto), hash = fingerprint(input); @@ -71,8 +75,10 @@ export class BankCardsService { const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); + } return this.cardView(tx, existing); } currentDate(input.openingDate); @@ -85,6 +91,7 @@ export class BankCardsService { ); }); } + updateCard(userId: string, id: string, dto: UpdateBankCardDto) { return this.repository.write(async (tx) => { const previous = await this.repository.card(tx, userId, id); @@ -93,17 +100,17 @@ export class BankCardsService { id, name: dto.name ?? previous.name, bankName: - dto.bankName === undefined - ? (previous.bankName ?? undefined) - : (dto.bankName ?? undefined), + dto.bankName === undefined ? + (previous.bankName ?? undefined) : + (dto.bankName ?? undefined), lastFour: - dto.lastFour === undefined - ? (previous.lastFour ?? undefined) - : (dto.lastFour ?? undefined), + dto.lastFour === undefined ? + (previous.lastFour ?? undefined) : + (dto.lastFour ?? undefined), paymentSystem: - dto.paymentSystem === undefined - ? previous.paymentSystem - : dto.paymentSystem, + dto.paymentSystem === undefined ? + previous.paymentSystem : + dto.paymentSystem, openingBalance: dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), openingDate: dto.openingDate ?? previous.openingDate, @@ -111,8 +118,9 @@ export class BankCardsService { const financial = input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingDate !== previous.openingDate; - if (financial && previous.archivedAt) + if (financial && previous.archivedAt) { conflict(BankErrorCode.CardArchived); + } currentDate(input.openingDate); if ( await tx.bankCardOperation.findFirst({ @@ -123,20 +131,21 @@ export class BankCardsService { OR: [{ cardId: id }, { targetCardId: id }], }, }) - ) + ) { fail(BankErrorCode.OpeningDateConflict); + } const changed = await tx.bankCard.updateMany({ where: { id, userId, version: dto.version }, data: { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, - ...(dto.archived === undefined - ? {} - : { - archivedAt: dto.archived - ? (previous.archivedAt ?? new Date()) - : null, + ...(dto.archived === undefined ? + {} : + { + archivedAt: dto.archived ? + (previous.archivedAt ?? new Date()) : + null, }), }, }); diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index 9b6cabd..d9dac92 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,20 +1,21 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { GoneException, Injectable } from '@nestjs/common'; + import { BankCardOperation, Prisma, } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; +import { BankOperationKind } from '../domain/bank-operation-kind'; +import { rubMoney } from '../domain/rub-money'; +import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankOperationDto, - DEFAULT_BANK_PAGE_SIZE, BankOperationsQueryDto, BankPageQueryDto, CreateBankOperationDto, + DEFAULT_BANK_PAGE_SIZE, UpdateBankOperationDto, } from '../presentation/bank-card.dto'; -import { rubMoney } from '../domain/rub-money'; - import { BankErrorCode, conflict, @@ -25,7 +26,6 @@ import { operationInput, periodBounds, } from './bank-card-input'; -import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { @@ -33,34 +33,35 @@ export class BankOperationsService { private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + private async operationView( tx: Tx, row: BankCardOperation, ): Promise { const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId - ? await this.repository.card(tx, row.userId, row.targetCardId) - : null; - const source = row.originalExpenseId - ? await this.repository.operation(tx, row.userId, row.originalExpenseId) - : null; + const target = row.targetCardId ? + await this.repository.card(tx, row.userId, row.targetCardId) : + null; + const source = row.originalExpenseId ? + await this.repository.operation(tx, row.userId, row.originalExpenseId) : + null; const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId - ? await tx.expenseCategory.findFirst({ + const category = categoryId ? + await tx.expenseCategory.findFirst({ where: { id: categoryId, userId: row.userId }, - }) - : null; + }) : + null; const refunds = - row.kind === BankOperationKind.Expense || source - ? await tx.bankCardOperation.findMany({ + row.kind === BankOperationKind.Expense || source ? + await tx.bankCardOperation.findMany({ where: { userId: row.userId, originalExpenseId: source?.id ?? row.id, deletedAt: null, ...(source ? { id: { not: row.id } } : {}), }, - }) - : []; + }) : + []; return { id: row.id, kind: row.kind as BankOperationDto['kind'], @@ -73,18 +74,19 @@ export class BankOperationsService { originalExpenseId: row.originalExpenseId, amount: rubMoney(row.amountKopecks), availableRefund: - row.kind === BankOperationKind.Expense || source - ? rubMoney( + row.kind === BankOperationKind.Expense || source ? + rubMoney( (source?.amountKopecks ?? row.amountKopecks) - - refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), - ) - : null, + refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), + ) : + null, date: row.date, description: row.description, version: row.version, createdAt: row.createdAt.toISOString(), }; } + private async validateOperation( tx: Tx, userId: string, @@ -102,23 +104,26 @@ export class BankOperationsService { if ( (id === input.cardId || id === input.targetCardId) && input.date < card.openingDate - ) + ) { fail(BankErrorCode.InvalidDate); + } } if ( old && (old.kind !== input.kind || old.originalExpenseId !== input.originalExpenseId) - ) + ) { fail(BankErrorCode.ImmutableOperation); + } if (input.kind === BankOperationKind.Transfer) { if ( !input.targetCardId || input.targetCardId === input.cardId || input.categoryId || input.originalExpenseId - ) + ) { fail(); + } } else if (input.targetCardId) fail(); if (input.kind === BankOperationKind.Expense) { if (input.originalExpenseId) fail(); @@ -133,11 +138,13 @@ export class BankOperationsService { !(await tx.expenseCategory.findFirst({ where: { userId, id: input.categoryId }, })) - ) + ) { missing(); + } } else if (input.categoryId) fail(); - if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) + if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { fail(); + } if (input.kind === BankOperationKind.Refund) { if (!input.originalExpenseId) fail(); const source = await this.repository.operation( @@ -149,8 +156,9 @@ export class BankOperationsService { source.deletedAt || source.kind !== BankOperationKind.Expense || source.cardId !== input.cardId - ) + ) { fail(BankErrorCode.InvalidRefund); + } if ( ( await tx.bankCardOperation.updateMany({ @@ -163,8 +171,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } const refunds = await tx.bankCardOperation.findMany({ where: { userId, @@ -176,10 +185,11 @@ export class BankOperationsService { if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + - input.amountKopecks > + input.amountKopecks > source.amountKopecks - ) + ) { conflict(BankErrorCode.RefundLimit); + } } if (old?.kind === BankOperationKind.Expense) { const refunds = await tx.bankCardOperation.findMany({ @@ -190,11 +200,13 @@ export class BankOperationsService { (input.cardId !== old.cardId || refunds.some((row) => row.date < input.date) || refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > - input.amountKopecks) - ) + input.amountKopecks) + ) { conflict(BankErrorCode.DependentRefunds); + } } } + createOperation(userId: string, dto: CreateBankOperationDto) { const normalized = operationInput(dto), hash = fingerprint(normalized); @@ -204,10 +216,13 @@ export class BankOperationsService { }); if (existing) { if (existing.userId !== userId) missing(); - if (existing.createFingerprint !== hash) + // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. + if (existing.createFingerprint !== hash) { conflict(BankErrorCode.IntentConflict); - if (existing.deletedAt) + } + if (existing.deletedAt) { throw new GoneException({ code: BankErrorCode.OperationDeleted }); + } return this.operationView(tx, existing); } const input = { ...normalized }; @@ -220,6 +235,7 @@ export class BankOperationsService { ); }); } + getOperation(userId: string, id: string) { return this.prisma.$transaction(async (tx) => { const row = await this.repository.operation(tx, userId, id); @@ -227,6 +243,7 @@ export class BankOperationsService { return this.operationView(tx, row); }); } + updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { return this.repository.write(async (tx) => { const old = await this.repository.operation(tx, userId, id); @@ -252,29 +269,33 @@ export class BankOperationsService { data: { ...input, version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } return this.operationView( tx, await this.repository.operation(tx, userId, id), ); }); } + deleteOperation(userId: string, id: string, version: number) { return this.repository.write(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) return; if (row.version !== version) conflict(); await this.repository.card(tx, userId, row.cardId, true); - if (row.targetCardId) + if (row.targetCardId) { await this.repository.card(tx, userId, row.targetCardId, true); + } if ( row.kind === BankOperationKind.Expense && (await tx.bankCardOperation.findFirst({ where: { userId, originalExpenseId: id, deletedAt: null }, })) - ) + ) { conflict(BankErrorCode.DependentRefunds); + } if (row.originalExpenseId) { const source = await this.repository.operation( tx, @@ -288,8 +309,9 @@ export class BankOperationsService { data: { version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } } if ( ( @@ -298,30 +320,32 @@ export class BankOperationsService { data: { deletedAt: new Date(), version: { increment: 1 } }, }) ).count !== 1 - ) + ) { conflict(); + } }); } + history( userId: string, id: string, query: BankOperationsQueryDto | BankPageQueryDto, expenses = false, ) { - const period = expenses - ? undefined - : periodBounds(query as BankOperationsQueryDto); + const period = expenses ? + undefined : + periodBounds(query as BankOperationsQueryDto); return this.prisma.$transaction(async (tx) => { await this.repository.card(tx, userId, id); - const page = query.page ?? 1, - limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; + const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, + page = query.page ?? 1; const where: Prisma.BankCardOperationWhereInput = { userId, deletedAt: null, OR: [{ cardId: id }, { targetCardId: id }], - ...(period - ? { date: { gte: period.from, lte: period.to } } - : { kind: BankOperationKind.Expense }), + ...(period ? + { date: { gte: period.from, lte: period.to } } : + { kind: BankOperationKind.Expense }), }; const total = await tx.bankCardOperation.count({ where }); const rows = await tx.bankCardOperation.findMany({ diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index f110154..80f2d77 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,16 +1,17 @@ -import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; -import { conflict, missing, name } from './bank-card-input'; +import { PrismaService } from '../../../../prisma/prisma.service'; +import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { conflict, missing, name } from './bank-card-input'; @Injectable() export class ExpenseCategoriesService { constructor( private readonly prisma: PrismaService, private readonly repository: BankCardsRepository, ) {} + async categories(userId: string) { return ( await this.prisma.expenseCategory.findMany({ @@ -24,6 +25,7 @@ export class ExpenseCategoriesService { version, })); } + createCategory(userId: string, value: string) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -44,6 +46,7 @@ export class ExpenseCategoriesService { }; }); } + updateCategory(userId: string, id: string, value: string, version: number) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); return this.repository.write(async (tx) => { @@ -60,8 +63,9 @@ export class ExpenseCategoriesService { }, }) ).count !== 1 - ) + ) { conflict(); + } return { id, name: label, diff --git a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts index 5b224fb..33fa467 100644 --- a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts @@ -1,10 +1,10 @@ import { Injectable } from '@nestjs/common'; -import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; + import { bankSummary } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; - -import { periodBounds } from './bank-card-input'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; +import { BankSummaryQueryDto } from '../presentation/bank-card.dto'; +import { periodBounds } from './bank-card-input'; @Injectable() export class GetBankSummaryUseCase { constructor(private readonly repository: BankCardsRepository) {} diff --git a/src/modules/banking/bank-cards/bank-cards.module.ts b/src/modules/banking/bank-cards/bank-cards.module.ts index e34a089..2b9e006 100644 --- a/src/modules/banking/bank-cards/bank-cards.module.ts +++ b/src/modules/banking/bank-cards/bank-cards.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { AuthModule } from '../../auth/auth.module'; -import { BankCardsController } from './presentation/bank-cards.controller'; -import { BankCardsRepository } from './infrastructure/bank-cards.repository'; import { BankCardsService } from './application/bank-cards.service'; import { BankOperationsService } from './application/bank-operations.service'; import { ExpenseCategoriesService } from './application/expense-categories.service'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; +import { BankCardsRepository } from './infrastructure/bank-cards.repository'; +import { BankCardsController } from './presentation/bank-cards.controller'; @Module({ imports: [AuthModule], controllers: [BankCardsController], diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index e3a5ae6..14ce726 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -22,10 +22,10 @@ export function bankBalance( cardId: string, opening: bigint, operations: Array<{ - kind: string; - cardId: string; - targetCardId: string | null; amountKopecks: bigint; + cardId: string; + kind: string; + targetCardId: null | string; }>, ): bigint { // ponytail: O(N) history fold; optimize only after measuring growing histories. @@ -35,19 +35,21 @@ export function bankBalance( if ( operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Transfer - ) + ) { balance -= operation.amountKopecks; - else if ( + } else if ( operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Refund - ) + ) { balance += operation.amountKopecks; + } } if ( operation.kind === BankOperationKind.Transfer && operation.targetCardId === cardId - ) + ) { balance += operation.amountKopecks; + } } return balance; } @@ -55,17 +57,17 @@ export function bankBalance( export function bankSummary( period: { from: string; to: string }, operations: Array<{ - kind: string; - date: string; amountKopecks: bigint; - categoryId: string | null; + categoryId: null | string; + date: string; + kind: string; }>, ) { let expenses = 0n; let refunds = 0n; const categories = new Map< string, - { categoryId: string; expenses: bigint; refunds: bigint; net: bigint } + { categoryId: string; expenses: bigint; net: bigint; refunds: bigint } >(); // ponytail: O(N) history fold; optimize only after measuring growing histories. for (const operation of operations) { @@ -73,16 +75,17 @@ export function bankSummary( if ( operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Refund - ) + ) { continue; + } const expense = - operation.kind === BankOperationKind.Expense - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Expense ? + operation.amountKopecks : + 0n; const refund = - operation.kind === BankOperationKind.Refund - ? operation.amountKopecks - : 0n; + operation.kind === BankOperationKind.Refund ? + operation.amountKopecks : + 0n; expenses += expense; refunds += refund; if (operation.categoryId !== null) { diff --git a/src/modules/banking/bank-cards/domain/rub-money.spec.ts b/src/modules/banking/bank-cards/domain/rub-money.spec.ts index 383c230..7c34f36 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.spec.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.spec.ts @@ -39,8 +39,9 @@ describe('RUB money', () => { }); it('requires positive operation amounts, including at the maximum', () => { - for (const value of ['0', '-0.00', '-0.01']) + for (const value of ['0', '-0.00', '-0.01']) { expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow(); + } expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect( parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index 2bb1b67..3fc9ccc 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -1,6 +1,7 @@ -import { decimalFromString, formatDecimal } from '../../../../common/decimal'; import type { Money } from '../../../../common/money.types'; +import { decimalFromString, formatDecimal } from '../../../../common/decimal'; + const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MAX_KOPECKS = 9_223_372_036_854_775_807n; @@ -9,16 +10,20 @@ export function parseRubMoney(money: Money, positive = false): bigint { money?.currency !== 'RUB' || typeof money.value !== 'string' || money.value !== money.value.trim() - ) + ) { throw new TypeError('Expected RUB money with a decimal string'); + } const amount = decimalFromString(money.value); - if (amount.scale > 2) + if (amount.scale > 2) { throw new RangeError('RUB money supports at most two decimal places'); + } const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); - if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) + if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) { throw new RangeError('RUB money is outside the signed 64-bit range'); - if (positive && kopecks <= 0n) + } + if (positive && kopecks <= 0n) { throw new RangeError('Operation amount must be positive'); + } return kopecks; } diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 48fab86..3d675ad 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,14 +1,14 @@ -import { BankOperationKind } from '../domain/bank-operation-kind'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { Prisma } from '../../../../../generated/prisma/client'; import { PrismaService } from '../../../../prisma/prisma.service'; - import { BankErrorCode, conflict, missing, } from '../application/bank-card-input'; +import { BankOperationKind } from '../domain/bank-operation-kind'; type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_TIMEOUT_MS = 15_000; @@ -36,33 +36,37 @@ export class BankCardsRepository { }); } catch (error) { const code = - error instanceof Prisma.PrismaClientKnownRequestError - ? error.code - : ''; + error instanceof Prisma.PrismaClientKnownRequestError ? + error.code : + ''; const busy = code === 'P2034' || code === 'P2028' || (error instanceof Error && /SQLITE_BUSY|database is locked/.test(error.message)); - if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) + if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { continue; + } if (busy) conflict(BankErrorCode.WriteConflict); if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); throw error; } } } + async card(tx: Tx, userId: string, id: string, active = false) { const card = await tx.bankCard.findFirst({ where: { id, userId } }); if (!card) return missing(); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); return card; } + async operation(tx: Tx, userId: string, id: string) { const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); if (!row) return missing(); return row; } + async seed(tx: Tx, userId: string) { for (const [index, label] of seeds.entries()) { const seedKey = index === seeds.length - 1 ? 'other' : String(index); @@ -70,7 +74,7 @@ export class BankCardsRepository { !(await tx.expenseCategory.findUnique({ where: { userId_seedKey: { userId, seedKey } }, })) - ) + ) { await tx.expenseCategory.create({ data: { id: randomUUID(), @@ -80,8 +84,10 @@ export class BankCardsRepository { nameKey: label.toLocaleLowerCase('ru-RU'), }, }); + } } } + summaryData( userId: string, period: { from: string; to: string }, diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index bb23f56..dfc9ac7 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -1,9 +1,3 @@ -import { - MAX_CARD_NAME_LENGTH, - MAX_CATEGORY_NAME_LENGTH, - MAX_OPERATION_DESCRIPTION_LENGTH, -} from '../domain/bank-card-limits'; -import { BankOperationKind } from '../domain/bank-operation-kind'; import { ApiProperty, ApiPropertyOptional, @@ -28,7 +22,14 @@ import { ValidateIf, ValidateNested, } from 'class-validator'; + import { MoneyDto } from '../../../../common/money.dto'; +import { + MAX_CARD_NAME_LENGTH, + MAX_CATEGORY_NAME_LENGTH, + MAX_OPERATION_DESCRIPTION_LENGTH, +} from '../domain/bank-card-limits'; +import { BankOperationKind } from '../domain/bank-operation-kind'; export const DEFAULT_BANK_PAGE_SIZE = 50; const MAX_BANK_PAGE_SIZE = 100; @@ -41,6 +42,7 @@ export class RubMoneyInputDto { @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @IsString() + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. @Matches(/^-?\d+(\.\d{1,2})?$/) declare value: string; } @@ -52,6 +54,7 @@ export class CreateBankCardDto { @MinLength(1) @MaxLength(MAX_CARD_NAME_LENGTH) declare name: string; + @ApiPropertyOptional({ type: String, nullable: true, @@ -61,20 +64,24 @@ export class CreateBankCardDto { @Transform(trim) @IsString() @MaxLength(MAX_CARD_NAME_LENGTH) - bankName?: string | null; + bankName?: null | string; + @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @IsOptional() - @Matches(/^[0-9]{4}$/) - lastFour?: string | null; + @Matches(/^\d{4}$/) + lastFour?: null | string; + @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @IsOptional() @IsIn(bankPaymentSystems) - paymentSystem?: string | null; + paymentSystem?: null | string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare openingBalance: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) @@ -106,28 +113,34 @@ export class CreateBankOperationDto { @ApiProperty({ enum: bankOperationKinds }) @IsIn(bankOperationKinds) declare kind: BankOperationKind; + @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() targetCardId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() categoryId?: string; + @ApiPropertyOptional({ format: 'uuid' }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsUUID() originalExpenseId?: string; + @ApiProperty({ type: RubMoneyInputDto }) @IsDefined() @ValidateNested() @Type(() => RubMoneyInputDto) declare amount: MoneyDto; + @ApiProperty({ format: 'date' }) @IsString() @Matches(/^\d{4}-\d{2}-\d{2}$/) declare date: string; + @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ValidateIf((_object, value: unknown) => value !== undefined) @IsString() @@ -153,6 +166,7 @@ export class BankPageQueryDto { @IsInt() @Min(1) page?: number; + @ApiPropertyOptional({ minimum: 1, maximum: MAX_BANK_PAGE_SIZE, @@ -173,6 +187,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-(0[1-9]|1[0-2])$/) month?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive start date; requires to.', @@ -180,6 +195,7 @@ export class BankPeriodQueryDto { @IsOptional() @Matches(/^\d{4}-\d{2}-\d{2}$/) from?: string; + @ApiPropertyOptional({ format: 'date', description: 'Inclusive end date; requires from.', @@ -215,16 +231,20 @@ export class BankCardDto { @ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String, nullable: true }) declare bankName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare lastFour: - string | null; + null | string; + @ApiProperty({ enum: bankPaymentSystems, nullable: true }) - declare paymentSystem: string | null; + declare paymentSystem: null | string; + @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: String, nullable: true }) declare archivedAt: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; } export class BankOperationDto { @@ -233,27 +253,35 @@ export class BankOperationDto { @ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String, nullable: true }) declare targetCardId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare targetCardName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryId: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare categoryName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare originalExpenseId: - string | null; + null | string; + @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto, nullable: true }) declare availableRefund: MoneyDto | null; + @ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String, nullable: true }) declare description: - string | null; + null | string; + @ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: String }) declare createdAt: string; } export class BankOperationsPageDto { @ApiProperty({ type: BankOperationDto, isArray: true }) declare items: BankOperationDto[]; + @ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Boolean }) declare hasNext: boolean; @@ -269,7 +297,7 @@ export class BankSummaryDto { @ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) to?: string; - @ApiProperty({ type: String, nullable: true }) declare cardId: string | null; + @ApiProperty({ type: String, nullable: true }) declare cardId: null | string; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto; diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index e484e23..0a3e19a 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -21,12 +23,16 @@ import { ApiTags, getSchemaPath, } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses, ProblemDetailsDto, } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; +import { BankCardsService } from '../application/bank-cards.service'; +import { BankOperationsService } from '../application/bank-operations.service'; +import { ExpenseCategoriesService } from '../application/expense-categories.service'; +import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; import { BankCardDto, BankCardsQueryDto, @@ -45,10 +51,6 @@ import { UpdateBankOperationDto, UpdateExpenseCategoryDto, } from './bank-card.dto'; -import { BankCardsService } from '../application/bank-cards.service'; -import { BankOperationsService } from '../application/bank-operations.service'; -import { ExpenseCategoriesService } from '../application/expense-categories.service'; -import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case'; type AuthRequest = Request & { user: AccessPayload }; @ApiResponse({ status: 409, @@ -82,26 +84,31 @@ export class BankCardsController { private readonly expenseCategories: ExpenseCategoriesService, private readonly bankSummary: GetBankSummaryUseCase, ) {} + @Get() @ApiOkResponse({ type: BankCardDto, isArray: true }) list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { return this.cards.list(req.user.sub, query.includeArchived === 'true'); } + @Post() @ApiCreatedResponse({ type: BankCardDto }) create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { return this.cards.createCard(req.user.sub, body); } + @Get('categories') @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) categories(@Req() req: AuthRequest) { return this.expenseCategories.categories(req.user.sub); } + @Post('categories') @ApiCreatedResponse({ type: ExpenseCategoryDto }) createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { return this.expenseCategories.createCategory(req.user.sub, body.name); } + @Patch('categories/:id') @ApiOkResponse({ type: ExpenseCategoryDto }) updateCategory( @@ -116,11 +123,13 @@ export class BankCardsController { body.version, ); } + @Get('summary') @ApiOkResponse({ type: BankSummaryDto }) summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { return this.bankSummary.execute(req.user.sub, query); } + @Post('operations') @ApiCreatedResponse({ type: BankOperationDto }) createOperation( @@ -129,11 +138,13 @@ export class BankCardsController { ) { return this.operations.createOperation(req.user.sub, body); } + @Get('operations/:id') @ApiOkResponse({ type: BankOperationDto }) operation(@Req() req: AuthRequest, @Param('id') id: string) { return this.operations.getOperation(req.user.sub, id); } + @Patch('operations/:id') @ApiOkResponse({ type: BankOperationDto }) updateOperation( @@ -143,6 +154,7 @@ export class BankCardsController { ) { return this.operations.updateOperation(req.user.sub, id, body); } + @Delete('operations/:id') @HttpCode(HttpStatus.NO_CONTENT) @ApiNoContentResponse() @@ -153,6 +165,7 @@ export class BankCardsController { ) { return this.operations.deleteOperation(req.user.sub, id, query.version); } + @Get(':id/operations') @ApiOkResponse({ type: BankOperationsPageDto }) history( @@ -162,6 +175,7 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query); } + @Get(':id/expenses') @ApiOkResponse({ type: BankOperationsPageDto }) expenses( @@ -171,11 +185,13 @@ export class BankCardsController { ) { return this.operations.history(req.user.sub, id, query, true); } + @Get(':id') @ApiOkResponse({ type: BankCardDto }) get(@Req() req: AuthRequest, @Param('id') id: string) { return this.cards.get(req.user.sub, id); } + @Patch(':id') @ApiOkResponse({ type: BankCardDto }) update( diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index 22c8506..214573d 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -1,6 +1,7 @@ -import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; + import { MoneyDto } from '../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../investment-resource.dto'; export class PortfolioSummaryDto { @ApiProperty({ type: MoneyDto, isArray: true }) @@ -40,15 +41,18 @@ export class BrokerageAccountDto { 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare openedOn: string | null; + declare openedOn: null | string; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field. @ApiProperty({ type: PortfolioSummaryDto, nullable: true }) - portfolioSummary?: PortfolioSummaryDto | null; + portfolioSummary?: null | PortfolioSummaryDto; } export class BrokerageAccountsResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true }) declare data: BrokerageAccountDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 564029c..85e2bce 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -1,9 +1,11 @@ -import { InvestmentReadQueryDto } from '../investment-resource.dto'; -import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index 267ea01..35f4e9f 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -1,7 +1,8 @@ -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsService } from './brokerage-accounts.service'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 2d20622..a9473d3 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -1,8 +1,8 @@ jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { BrokerageAccountsService } from './brokerage-accounts.service'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { BrokerageAccountsService } from './brokerage-accounts.service'; jest.mock('../portfolio/application/portfolio.service', () => ({ PortfolioService: class PortfolioService {}, diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index b8a0111..d2c1234 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -1,7 +1,8 @@ import { Injectable } from '@nestjs/common'; import { createHash } from 'node:crypto'; -import { PortfolioService } from '../portfolio/portfolio.api'; + import { investmentResourceMeta } from '../investment-resource.mapper'; +import { PortfolioService } from '../portfolio/portfolio.api'; import { BrokerageAccountDto } from './brokerage-account.dto'; @Injectable() @@ -11,15 +12,15 @@ export class BrokerageAccountsService { async list(userId: string, observe = false) { const saved = await this.portfolio.saved(userId, 'all', observe); const data: BrokerageAccountDto[] | null = - saved.membership.availability === 'missing' - ? null - : saved.accounts.map((account) => ({ + saved.membership.availability === 'missing' ? + null : + saved.accounts.map((account) => ({ id: account.id, name: account.name, type: account.type as BrokerageAccountDto['type'], openedOn: account.openedOn, - portfolioSummary: account.snapshot - ? { + portfolioSummary: account.snapshot ? + { totalsByCurrency: account.snapshot.totalsByCurrency, stockPositionCount: account.snapshot.positions.filter( (position) => position.type === 'STOCK', @@ -27,8 +28,8 @@ export class BrokerageAccountsService { bondPositionCount: account.snapshot.positions.filter( (position) => position.type === 'BOND', ).length, - } - : null, + } : + null, })); return { data, @@ -46,18 +47,18 @@ export class BrokerageAccountsService { ) .digest('base64url'), availability: - data === null - ? 'missing' - : saved.accounts.some((account) => !account.snapshot) - ? 'partial' - : 'complete', - fetchedAt: saved.membership.lastSuccessfulAt - ? new Date(saved.membership.lastSuccessfulAt) - : null, + data === null ? + 'missing' : + saved.accounts.some((account) => !account.snapshot) ? + 'partial' : + 'complete', + fetchedAt: saved.membership.lastSuccessfulAt ? + new Date(saved.membership.lastSuccessfulAt) : + null, freshForMs: 300_000, - work: saved.membership.sync.id - ? { ...saved.membership.sync, id: saved.membership.sync.id } - : null, + work: saved.membership.sync.id ? + { ...saved.membership.sync, id: saved.membership.sync.id } : + null, dependencies: saved.accounts.map((account) => account.meta), }), }; diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index 5d08d42..d72f624 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -1,14 +1,16 @@ +import type { Request } from 'express'; + import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { - IncomeAnalyticsResourceDto, IncomeAnalyticsQueryDto, + IncomeAnalyticsResourceDto, IncomeSummaryResourceDto, } from './income-analytics.dto'; -import { InvestmentReadQueryDto } from '../investment-resource.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; @ApiTags('brokerage-accounts') diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index d133732..a398d59 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -1,13 +1,13 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsOptional, Matches } from 'class-validator'; -import { MoneyDto } from '../../../common/money.dto'; -import { BrokerageOperationDto } from '../operations/operations.api'; +import { MoneyDto } from '../../../common/money.dto'; import { InvestmentDependencyDto, InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; +import { BrokerageOperationDto } from '../operations/operations.api'; const dateOnly = /^\d{4}-\d{2}-\d{2}$/; @@ -102,16 +102,16 @@ export class IncomeInstrumentDto { declare key: string; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty({ type: String, nullable: true }) - declare instrumentUid: string | null; + declare instrumentUid: null | string; @ApiProperty({ type: String, nullable: true }) - declare ticker: string | null; + declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) - declare instrumentType: string | null; + declare instrumentType: null | string; @ApiProperty() declare displayName: string; @@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto { declare yearComparison: IncomeComparisonDto | null; @ApiProperty({ enum: ['day', 'week', 'month'] }) - declare bucketGranularity: 'day' | 'week' | 'month'; + declare bucketGranularity: 'day' | 'month' | 'week'; @ApiProperty({ type: IncomeBucketDto, isArray: true }) declare buckets: IncomeBucketDto[]; @@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto { export class IncomeSummaryResourceDto { @ApiProperty({ type: IncomeSummaryDto, nullable: true }) declare data: IncomeSummaryDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto { } export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto. @ApiProperty({ type: IncomeDependencyDto, isArray: true, @@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto { export class IncomeAnalyticsResourceDto { @ApiProperty({ type: IncomeAnalyticsDto, nullable: true }) declare data: IncomeAnalyticsDto | null; + @ApiProperty({ type: IncomeAnalyticsMetaDto }) declare meta: IncomeAnalyticsMetaDto; } diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index fa7c3d9..0aef960 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({ })); import assert from 'node:assert/strict'; -import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto'; + import { investmentResourceMeta } from '../investment-resource.mapper'; +import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto'; import { IncomeAnalyticsService } from './income-analytics.service'; describe('IncomeAnalyticsService', () => { diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 824f1eb..76fd624 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -1,5 +1,6 @@ -import assert from 'node:assert/strict'; import { Injectable } from '@nestjs/common'; +import assert from 'node:assert/strict'; + import { add, compare, @@ -7,24 +8,21 @@ import { formatDecimal, subtract, } from '../../../common/decimal'; -import { investmentResourceMeta } from '../investment-resource.mapper'; -import { - IncomeAnalyticsResourceDto, - IncomeDependencyDto, - IncomeSummaryResourceDto, -} from './income-analytics.dto'; import { DateOnlyRange, moscowDate } from '../investment-calendar'; +import { investmentResourceMeta } from '../investment-resource.mapper'; import { BrokerageOperationDto, BrokerageOperationsService, } from '../operations/operations.api'; - import { IncomeAnalyticsEntryDto, + IncomeAnalyticsResourceDto, IncomeComparisonDto, IncomeCurrencySummaryDto, + IncomeDependencyDto, IncomeInstrumentDto, IncomeSummaryDto, + IncomeSummaryResourceDto, } from './income-analytics.dto'; import { IncomeBucketGranularity, @@ -96,9 +94,9 @@ export class IncomeAnalyticsService { { ...current.meta, role: 'selected' }, { ...prior.meta, role: 'previous' }, { - ...(plan.yearAgo && year - ? year.meta - : investmentResourceMeta({ + ...(plan.yearAgo && year ? + year.meta : + investmentResourceMeta({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier dataRevision: 'unavailable', availability: 'missing', @@ -119,9 +117,9 @@ export class IncomeAnalyticsService { resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, dataRevision: current.meta.dataRevision, availability: current.meta.availability, - fetchedAt: current.meta.lastSuccessfulAt - ? new Date(current.meta.lastSuccessfulAt) - : null, + fetchedAt: current.meta.lastSuccessfulAt ? + new Date(current.meta.lastSuccessfulAt) : + null, freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, work: work?.id ? { ...work, id: work.id } : null, coverage: current.meta.coverage, @@ -144,13 +142,13 @@ export class IncomeAnalyticsService { current.meta.lastSuccessfulAt, ), previous: - prior.items === null - ? null - : comparison(selected, plan.previous, qualifying(prior.items)), + prior.items === null ? + null : + comparison(selected, plan.previous, qualifying(prior.items)), yearComparison: - plan.yearAgo && year?.items !== null && year - ? comparison(selected, plan.yearAgo, qualifying(year.items)) - : null, + plan.yearAgo && year?.items !== null && year ? + comparison(selected, plan.yearAgo, qualifying(year.items)) : + null, bucketGranularity: plan.granularity, buckets: buckets(plan.selected, plan.granularity, selected), instruments: instruments(selected), @@ -184,9 +182,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] { if (!entry.payment) continue; const total = values.get(entry.payment.currency) ?? zeroTotal(); const value = decimalFromString(entry.payment.value); - if (entry.type === 'OPERATION_TYPE_COUPON') + if (entry.type === 'OPERATION_TYPE_COUPON') { total.coupon = add(total.coupon, value); - else total.dividend = add(total.dividend, value); + } else total.dividend = add(total.dividend, value); values.set(entry.payment.currency, total); } return [...values] @@ -207,6 +205,7 @@ function comparison( ); const currencies = [ ...new Set([...selected.keys(), ...compared.keys()]), + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. ].sort(); return { period: comparedPeriod, @@ -279,8 +278,9 @@ function instruments( function instrumentKey(entry: BrokerageOperationDto): string { if (entry.instrumentUid) return `uid:${entry.instrumentUid}`; if (entry.figi) return `figi:${entry.figi}`; - if (entry.ticker || entry.instrumentType) + if (entry.ticker || entry.instrumentType) { return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`; + } return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; } diff --git a/src/modules/investments/income/income-period.spec.ts b/src/modules/investments/income/income-period.spec.ts index 36a17bd..31afc16 100644 --- a/src/modules/investments/income/income-period.spec.ts +++ b/src/modules/investments/income/income-period.spec.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; + import { dateOnlyBounds } from '../investment-calendar'; import { planIncomePeriod, rangeBuckets } from './income-period'; @@ -17,8 +18,9 @@ describe('income period planning', () => { ['2026-03-02', '2026-03-01'], ['2026-01-01', '2027-01-02'], ['2026-01-01', '2026-04-01'], - ]) + ]) { expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException); + } }); it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => { diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index e6e242e..c7c280d 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -1,5 +1,6 @@ -import { MILLISECONDS_PER_DAY } from '../../../common/time'; import { BadRequestException } from '@nestjs/common'; + +import { MILLISECONDS_PER_DAY } from '../../../common/time'; import { DateOnlyRange, dateParts, @@ -13,41 +14,41 @@ const DAYS_PER_WEEK = 7; const ISO_MONTH_LENGTH = 7; const ISO_YEAR_LENGTH = 4; -export type IncomeBucketGranularity = 'day' | 'week' | 'month'; +export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export function planIncomePeriod( from: string | undefined, to: string | undefined, openedDate?: Date, ): { - selected: DateOnlyRange; - previous: DateOnlyRange; - yearAgo: DateOnlyRange | null; granularity: IncomeBucketGranularity; + previous: DateOnlyRange; + selected: DateOnlyRange; + yearAgo: DateOnlyRange | null; } { if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); const today = moscowDate(new Date()); const selected = from && to ? { from, to } : { from: firstOfMonth(today), to: today }; assertRange(selected, today); - const previous = isFullMonth(selected) - ? previousMonth(selected.from) - : shiftDays(selected, -rangeDays(selected)); + const previous = isFullMonth(selected) ? + previousMonth(selected.from) : + shiftDays(selected, -rangeDays(selected)); const candidate = yearAgo(selected); const yearAgoRange = - candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) - ? candidate - : null; + candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ? + candidate : + null; return { selected, previous, yearAgo: yearAgoRange, granularity: - rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS - ? 'day' - : rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS - ? 'week' - : 'month', + rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ? + 'day' : + rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ? + 'week' : + 'month', }; } @@ -55,24 +56,25 @@ export function rangeBuckets( range: DateOnlyRange, granularity: IncomeBucketGranularity, ): DateOnlyRange[] { - if (granularity === 'day') + if (granularity === 'day') { return dates(range).map((date) => ({ from: date, to: date })); + } const buckets: DateOnlyRange[] = []; let cursor = - granularity === 'week' - ? addDays( + granularity === 'week' ? + addDays( range.from, -( (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % DAYS_PER_WEEK ), - ) - : `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; + ) : + `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; while (cursor <= range.to) { const end = - granularity === 'week' - ? addDays(cursor, DAYS_PER_WEEK - 1) - : endOfMonth(cursor); + granularity === 'week' ? + addDays(cursor, DAYS_PER_WEEK - 1) : + endOfMonth(cursor); buckets.push({ from: cursor < range.from ? range.from : cursor, to: end > range.to ? range.to : end, @@ -86,7 +88,7 @@ export function rangeDays(range: DateOnlyRange): number { return ( Math.round( (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / - MILLISECONDS_PER_DAY, + MILLISECONDS_PER_DAY, ) + 1 ); } @@ -109,8 +111,9 @@ function addDays(value: string, days: number): string { function dates(range: DateOnlyRange): string[] { const result: string[] = []; - for (let date = range.from; date <= range.to; date = addDays(date, 1)) + for (let date = range.from; date <= range.to; date = addDays(date, 1)) { result.push(date); + } return result; } @@ -142,9 +145,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null { const shift = (value: string) => { const [year, month, day] = dateParts(value); const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; - return utcDate(result).toISOString().slice(0, 10) === result - ? result - : null; + return utcDate(result).toISOString().slice(0, 10) === result ? + result : + null; }; const from = shift(range.from); const to = shift(range.to); diff --git a/src/modules/investments/income/income.module.ts b/src/modules/investments/income/income.module.ts index 3185170..addc0b5 100644 --- a/src/modules/investments/income/income.module.ts +++ b/src/modules/investments/income/income.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { AuthModule } from '../../auth/auth.module'; import { OperationsModule } from '../operations/operations.module'; import { IncomeAnalyticsController } from './income-analytics.controller'; diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index aacc939..86d7ba5 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -1,13 +1,13 @@ -import { planInvestmentScreen } from '../../investment-sync-plan'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_DAY } from '../../../../common/time'; + import { formatDecimal } from '../../../../common/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, @@ -16,35 +16,36 @@ import { type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { CatalogEnrichmentService } from './catalog-enrichment.service'; -import { CandleRepository } from '../infrastructure/candle.repository'; +import { planInvestmentScreen } from '../../investment-sync-plan'; import { candleGaps, + type CandleRange, candleWindows, utcDay, - type CandleRange, } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { timestamp } from './instrument-market'; type CandleInput = { - uid: string; - instrumentType: 'share' | 'bond'; - period: 'month' | 'halfYear' | 'year' | 'all'; + instrumentType: 'bond' | 'share'; + period: 'all' | 'halfYear' | 'month' | 'year'; range?: CandleRange; + uid: string; }; type Producer = { - input: CandleInput; attempt: TbankSyncAttempt; - now: Date; force: boolean; - priority: number; + input: CandleInput; + now: Date; observers: TbankSyncAttempt[]; pass?: { - now: Date; force: boolean; - ranges: CandleRange[]; nextWindow: number; + now: Date; + ranges: CandleRange[]; }; + priority: number; }; const months = { month: 1, halfYear: 6, year: 12 }; const tailFreshForMs = 60_000; @@ -61,18 +62,20 @@ function rangeFor( const from = utcDay(now); const history = timestamp(instrument.first_1dayCandleDate); if (input.period === 'all') { - if (!history) + if (!history) { throw new ServiceUnavailableException({ code: - input.instrumentType === 'share' - ? 'STOCK_HISTORY_START_UNAVAILABLE' - : 'BOND_HISTORY_START_UNAVAILABLE', + input.instrumentType === 'share' ? + 'STOCK_HISTORY_START_UNAVAILABLE' : + 'BOND_HISTORY_START_UNAVAILABLE', }); + } from.setTime(utcDay(new Date(history)).valueOf()); } else { from.setUTCMonth(from.getUTCMonth() - months[input.period]); - if (history && Date.parse(history) > from.valueOf()) + if (history && Date.parse(history) > from.valueOf()) { from.setTime(utcDay(new Date(history)).valueOf()); + } } return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; } @@ -102,16 +105,18 @@ export class CandleService { true, ['detail'], ); - if (detail.meta.availability === 'complete' && !detail.instrument) + if (detail.meta.availability === 'complete' && !detail.instrument) { throw new NotFoundException(); + } const now = new Date(); - const range = detail.instrument - ? rangeFor(input, detail.instrument, now) - : null; + const range = detail.instrument ? + rangeFor(input, detail.instrument, now) : + null; const saved = range ? await this.repository.read(input.uid, range) : null; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; - if (!observe && (!saved || gaps.length)) + if (!observe && (!saved || gaps.length)) { await this.request(userId, revision, input, now); + } const work = await this.repository.work(userId, revision, scope(input)); const fetchedAt = saved?.coverage @@ -129,14 +134,14 @@ export class CandleService { }); return { data: - !saved || !covered - ? null - : { + !saved || !covered ? + null : + { candles: saved.data, currency: - input.instrumentType === 'share' - ? detail.instrument?.currency?.toUpperCase() || null - : null, + input.instrumentType === 'share' ? + detail.instrument?.currency?.toUpperCase() || null : + null, historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), observedAt: fetchedAt?.toISOString() ?? now.toISOString(), }, @@ -149,8 +154,8 @@ export class CandleService { work, coverage: { requested: range ? [publicRange(range)] : [], - completeRanges: range - ? completeCoverage.flatMap((row) => { + completeRanges: range ? + completeCoverage.flatMap((row) => { const from = new Date( Math.max(row.from.valueOf(), range.from.valueOf()), ); @@ -158,22 +163,22 @@ export class CandleService { Math.min(row.to.valueOf(), range.to.valueOf()), ); return from < to ? [publicRange({ from, to })] : []; - }) - : [], + }) : + [], missingRanges: missing.map(publicRange), asOf: fetchedAt?.toISOString() ?? null, - historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : work?.state === 'failed' - ? 'failed' - : work?.state === 'running' - ? 'loading' - : covered - ? 'partial' - : 'missing', + historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + work?.state === 'failed' ? + 'failed' : + work?.state === 'running' ? + 'loading' : + covered ? + 'partial' : + 'missing', }, dependencies: [ detail.meta, @@ -183,9 +188,9 @@ export class CandleService { dataRevision: String(row.dataRevision), availability: 'complete', fetchedAt: row.fetchedAt, - freshForMs: row.isComplete - ? Number.POSITIVE_INFINITY - : tailFreshForMs, + freshForMs: row.isComplete ? + Number.POSITIVE_INFINITY : + tailFreshForMs, work: null, }), ), @@ -197,13 +202,14 @@ export class CandleService { prefetch( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const key = JSON.stringify([ userId, revision, positions .map((position) => [position.instrumentUid, position.type]) + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. .sort(), ]); const existing = this.prefetches.get(key); @@ -218,14 +224,14 @@ export class CandleService { private async prefetchPositions( userId: string, revision: string, - positions: { instrumentUid?: string | null; type: string }[], + positions: { instrumentUid?: null | string; type: string }[], ) { const now = new Date(); const unique = new Map( positions .filter( (position) => - position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), + position.instrumentUid && ['BOND', 'STOCK'].includes(position.type), ) .map((position) => [position.instrumentUid ?? '', position]), ); @@ -267,7 +273,7 @@ export class CandleService { instruments: [ { uid, - type: position.type as 'STOCK' | 'BOND', + type: position.type as 'BOND' | 'STOCK', historyFrom: history ? new Date(history) : null, }, ], @@ -280,9 +286,9 @@ export class CandleService { ) { const input = { uid, instrumentType, period: 'all' as const }; const range = - detail?.instrument && history - ? rangeFor(input, detail.instrument, now) - : null; + detail?.instrument && history ? + rangeFor(input, detail.instrument, now) : + null; if ( !range || candleGaps( @@ -290,8 +296,9 @@ export class CandleService { coverage.filter((row) => row.instrumentUid === uid), now, ).length - ) + ) { await this.request(userId, revision, input, now, false, 2); + } } } } @@ -379,6 +386,7 @@ export class CandleService { } const { ranges, now, force } = pass; const index = pass.nextWindow; + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. let window = ranges[index]; if (window && !force) { const latest = await this.repository.read(input.uid, window); @@ -386,6 +394,7 @@ export class CandleService { .flatMap(candleWindows) .reverse(); ranges.splice(index, 1, ...gaps); + // eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor. window = ranges[index]; } if (window) { @@ -408,8 +417,9 @@ export class CandleService { }, ]; }); - if (!(await this.repository.stage(attempt, window, rows, now))) + if (!(await this.repository.stage(attempt, window, rows, now))) { return false; + } if ( !force && !(await this.repository.publish( @@ -417,8 +427,9 @@ export class CandleService { { uid: input.uid, ranges: [window] }, false, )) - ) + ) { return false; + } pass.nextWindow = index + 1; if (pass.nextWindow < ranges.length) return null; } @@ -441,16 +452,18 @@ export class CandleService { } success = result; } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); + } if (error instanceof ServiceUnavailableException) { const response = error.getResponse(); if ( typeof response === 'object' && 'code' in response && typeof response.code === 'string' - ) + ) { code = response.code; + } } } // Seal publication with reservations so a late forced join cannot be @@ -458,8 +471,9 @@ export class CandleService { const finished = this.reservations.then(async () => { const pass = producer.pass; if (success && pass) { - if (producer.force && (!pass.force || producer.now > pass.now)) + if (producer.force && (!pass.force || producer.now > pass.now)) { return false; + } try { success = await this.repository.publish(producer.attempt, { uid: producer.input.uid, @@ -478,8 +492,9 @@ export class CandleService { observer, producer.attempt, )) - ) + ) { await this.repository.fail(observer, code); + } this.active.delete(observer.attemptId); } this.producers.delete(scope(producer.input)); @@ -490,7 +505,7 @@ export class CandleService { this.queue.push(producer); continue; } - if (success && !producer.force && producer.input.period !== 'all') + if (success && !producer.force && producer.input.period !== 'all') { await this.request( producer.attempt.userId, producer.attempt.revision, @@ -499,6 +514,7 @@ export class CandleService { false, 2, ); + } } } catch { this.logger.event('error', 'candles.persistence-failed', { diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index ae06df5..0c2b417 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -1,10 +1,10 @@ -import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { + ConflictException, Injectable, NotFoundException, - ConflictException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { TbankAccessService, @@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper'; import { CatalogDiscoveryRepository, catalogQuery, - catalogScope, type CatalogQuery, + catalogScope, } from '../infrastructure/catalog-discovery.repository'; +import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { catalogSearchLimit, selectCatalogInstruments, @@ -28,10 +29,10 @@ import { const tickerAmbiguityLimit = 2; type Producer = { - input: CatalogQuery; attempt: TbankSyncAttempt; - observers: TbankSyncAttempt[]; force: boolean; + input: CatalogQuery; + observers: TbankSyncAttempt[]; }; @Injectable() export class CatalogDiscoveryService { @@ -43,8 +44,9 @@ export class CatalogDiscoveryService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -73,14 +75,14 @@ export class CatalogDiscoveryService { async search(userId: string, input: CatalogQuery, observe = false) { const discovery = await this.read(userId, input, observe); - const sources = discovery.data - ? await this.enrichment.readMany( + const sources = discovery.data ? + await this.enrichment.readMany( userId, input.instrumentType, discovery.data.map((item) => item.uid), observe, - ) - : []; + ) : + []; const dependencies = [ discovery.meta, ...sources.map((source) => source.meta), @@ -108,22 +110,22 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: - discovery.data === null - ? 'missing' - : sources.every((source) => source.meta.availability === 'complete') - ? 'complete' - : 'partial', - fetchedAt: discovery.meta.lastSuccessfulAt - ? new Date(discovery.meta.lastSuccessfulAt) - : null, + discovery.data === null ? + 'missing' : + sources.every((source) => source.meta.availability === 'complete') ? + 'complete' : + 'partial', + fetchedAt: discovery.meta.lastSuccessfulAt ? + new Date(discovery.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.catalog, - work: discovery.meta.sync.id - ? { + work: discovery.meta.sync.id ? + { id: discovery.meta.sync.id, state: discovery.meta.sync.state, errorCode: discovery.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -131,7 +133,7 @@ export class CatalogDiscoveryService { async detail( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, observe = false, ) { @@ -143,22 +145,24 @@ export class CatalogDiscoveryService { ['detail'], ); const source = sources[0]; - if (source.meta.availability === 'complete' && !source.instrument) + if (source.meta.availability === 'complete' && !source.instrument) { throw new NotFoundException(); + } const instrument = source.instrument; let slug = uid; const dependencies = [source.meta]; if ( instrument?.ticker && - /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker) + /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker) ) { const alias = await this.read( userId, { instrumentType, query: instrument.ticker, exactTicker: true }, observe, ); - if (alias.data?.length === 1 && alias.data[0].uid === uid) + if (alias.data?.length === 1 && alias.data[0].uid === uid) { slug = instrument.ticker.toUpperCase(); + } dependencies.push(alias.meta); } return { @@ -169,17 +173,17 @@ export class CatalogDiscoveryService { .map((dependency) => dependency.dataRevision) .join('/'), availability: source.meta.availability, - fetchedAt: source.meta.lastSuccessfulAt - ? new Date(source.meta.lastSuccessfulAt) - : null, + fetchedAt: source.meta.lastSuccessfulAt ? + new Date(source.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: source.meta.sync.id - ? { + work: source.meta.sync.id ? + { id: source.meta.sync.id, state: source.meta.sync.state, errorCode: source.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -187,7 +191,7 @@ export class CatalogDiscoveryService { async byTicker( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ticker: string, observe = false, ) { @@ -197,38 +201,39 @@ export class CatalogDiscoveryService { observe, ); if (lookup.data?.length === 0) throw new NotFoundException(); - if (lookup.data && lookup.data.length > 1) + if (lookup.data && lookup.data.length > 1) { throw new ConflictException({ code: - instrumentType === 'share' - ? 'STOCK_TICKER_AMBIGUOUS' - : 'BOND_TICKER_AMBIGUOUS', + instrumentType === 'share' ? + 'STOCK_TICKER_AMBIGUOUS' : + 'BOND_TICKER_AMBIGUOUS', }); - const result = lookup.data - ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) - : null; + } + const result = lookup.data ? + await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : + null; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; return { - data: result?.data - ? { ...result.data, slug: ticker.trim().toUpperCase() } - : null, + data: result?.data ? + { ...result.data, slug: ticker.trim().toUpperCase() } : + null, meta: investmentResourceMeta({ resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), availability: result?.meta.availability ?? 'missing', - fetchedAt: lookup.meta.lastSuccessfulAt - ? new Date(lookup.meta.lastSuccessfulAt) - : null, + fetchedAt: lookup.meta.lastSuccessfulAt ? + new Date(lookup.meta.lastSuccessfulAt) : + null, freshForMs: tbankSourceTtlMs.metadata, - work: lookup.meta.sync.id - ? { + work: lookup.meta.sync.id ? + { id: lookup.meta.sync.id, state: lookup.meta.sync.state, errorCode: lookup.meta.sync.errorCode, - } - : null, + } : + null, dependencies, }), }; @@ -245,8 +250,9 @@ export class CatalogDiscoveryService { const saved = await this.repository.read(input); if ( !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) - ) + ) { return null; + } const reservation = await this.repository.reserve( { userId, revision }, input, @@ -291,12 +297,13 @@ export class CatalogDiscoveryService { async refresh(userId: string, revision: string, query: CatalogQuery) { const id = await this.request(userId, revision, query, true); - if (id) + if (id) { await Promise.all( [...this.completions.values()] .filter((entry) => entry.workId === id) .map((entry) => entry.done), ); + } const saved = await this.repository.read(catalogQuery(query)); const sources = await this.enrichment.readMany( userId, @@ -323,19 +330,19 @@ export class CatalogDiscoveryService { const token = await this.access.tokenForAttempt(attempt); if (token) { const found = - input.instrumentType === 'share' - ? await this.tbank.findShares(token, input.query) - : await this.tbank.findBonds(token, input.query); + input.instrumentType === 'share' ? + await this.tbank.findShares(token, input.query) : + await this.tbank.findBonds(token, input.query); success = await this.repository.publish( attempt, input, selectCatalogInstruments( - input.exactTicker - ? found.filter( + input.exactTicker ? + found.filter( (item) => item.ticker?.trim().toUpperCase() === input.query, - ) - : found, + ) : + found, input.query, input.instrumentType, ).slice( @@ -346,17 +353,18 @@ export class CatalogDiscoveryService { ); } } catch (error) { - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(attempt); - else + } else { this.logger.event('warn', 'catalog-discovery.failed', { code: 'TBANK_UNAVAILABLE', }); + } } if (success) { const saved = await this.repository.read(input); - for (const observer of [attempt, ...producer.observers]) - if (!input.exactTicker || saved.data?.length === 1) + for (const observer of [attempt, ...producer.observers]) { + if (!input.exactTicker || saved.data?.length === 1) { await this.enrichment.requestMany( observer.userId, observer.revision, @@ -365,6 +373,8 @@ export class CatalogDiscoveryService { producer.force, input.exactTicker ? ['detail'] : ['last-price', 'detail'], ); + } + } } this.producers.delete(catalogScope(input)); for (const observer of [attempt, ...producer.observers]) { diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 2d29778..712c1db 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -1,47 +1,47 @@ -import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; -import { CandleRepository } from '../infrastructure/candle.repository'; -import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { Injectable, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { decimalFromString, divide, formatDecimal, } from '../../../../common/decimal'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MILLISECONDS_PER_DAY, MILLISECONDS_PER_SECOND, } from '../../../../common/time'; -import { MoexClient } from '../../../../integrations/moex/moex.api'; +import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; -import { previousClose, timestamp } from './instrument-market'; -import { Injectable, NotFoundException } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankAccessService, - TbankClient, - tbankSourceNeedsRefresh, - tbankSourceTtlMs, type TbankBond, type TbankCandle, + TbankClient, type TbankCoupon, type TbankDividend, type TbankFundamentals, type TbankLastPrice, type TbankShare, + tbankSourceNeedsRefresh, + tbankSourceTtlMs, type TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; +import { candleGaps, utcDay } from '../domain/candle-coverage'; +import { CandleRepository } from '../infrastructure/candle.repository'; import { CatalogDiscoveryRepository, catalogScope, type CatalogSource, } from '../infrastructure/catalog-discovery.repository'; import { catalogSearchLimit } from './catalog-selection'; +import { previousClose, timestamp } from './instrument-market'; type Producer = { - input: CatalogSource; attempt: TbankSyncAttempt; + input: CatalogSource; observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; }; @Injectable() @@ -54,13 +54,13 @@ export class CatalogEnrichmentService { private running = false; private readonly completions = new Map< string, - { workId: string; done: Promise; resolve: () => void } + { done: Promise; resolve: () => void; workId: string } >(); async ensureDetail( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, ) { const ids = await this.requestMany( @@ -82,6 +82,7 @@ export class CatalogEnrichmentService { if (!rows.length) throw new Error('Instrument dependency missing'); return (rows[0].payload as CatalogPayload).instrument ?? null; } + constructor( private readonly repository: CatalogDiscoveryRepository, private readonly access: TbankAccessService, @@ -94,21 +95,21 @@ export class CatalogEnrichmentService { async readMany( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], observe: boolean, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const { revision } = await this.access.local(userId); const inputs = sourceInputs(instrumentType, uids, kinds, range); const rows = inputs.length ? await this.repository.readSources(inputs) : []; - if (!observe) + if (!observe) { await this.requestMany( userId, revision, @@ -118,9 +119,10 @@ export class CatalogEnrichmentService { kinds, range, ); - const work = inputs.length - ? await this.repository.workMany(userId, revision, inputs) - : []; + } + const work = inputs.length ? + await this.repository.workMany(userId, revision, inputs) : + []; const byScope = new Map(work.map((row) => [row.scopeKey, row])); return inputs.map((input) => { const row = rows.find( @@ -130,16 +132,16 @@ export class CatalogEnrichmentService { const payload = row?.payload as CatalogPayload | undefined; return { input, - version: row - ? { + version: row ? + { instrumentUid: row.instrumentUid, source: row.source, kind: row.kind, rangeKey: row.rangeKey, dataRevision: row.dataRevision, fetchedAt: row.fetchedAt, - } - : null, + } : + null, payload, instrument: payload?.instrument ?? null, price: payload?.price ?? null, @@ -158,22 +160,22 @@ export class CatalogEnrichmentService { requestMany( userId: string, revision: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], force = false, kinds: CatalogSource['kind'][] = ['last-price', 'detail'], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { - const rows = inputs.length - ? await this.repository.readSources(inputs) - : []; + const rows = inputs.length ? + await this.repository.readSources(inputs) : + []; const ids: string[] = []; for (const input of inputs) { const row = rows.find( @@ -187,8 +189,9 @@ export class CatalogEnrichmentService { new Date(), force, ) - ) + ) { continue; + } const reserved = await this.repository.reserve( { userId, revision }, input, @@ -229,24 +232,24 @@ export class CatalogEnrichmentService { async section( userId: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uid: string, - section: 'quote' | 'company' | 'metrics', + section: 'company' | 'metrics' | 'quote', observe = false, ) { const kinds: CatalogSource['kind'][] = - section === 'company' - ? ['detail', 'description', 'fundamentals'] - : [ - ...(instrumentType === 'bond' - ? ['bond-current' as const] - : ['detail' as const]), + section === 'company' ? + ['detail', 'description', 'fundamentals'] : + [ + ...(instrumentType === 'bond' ? + ['bond-current' as const] : + ['detail' as const]), 'last-price', 'trading-status', 'comparison', - ...(section === 'metrics' - ? ['coupons' as const, 'yield' as const, 'rating' as const] - : []), + ...(section === 'metrics' ? + ['coupons' as const, 'yield' as const, 'rating' as const] : + []), ]; const sources = await this.readMany( userId, @@ -256,8 +259,9 @@ export class CatalogEnrichmentService { kinds, ); const first = sources[0]; - if (first.meta.availability === 'complete' && !first.instrument) + if (first.meta.availability === 'complete' && !first.instrument) { throw new NotFoundException(); + } const values = sources.reduce( (result, source) => ({ ...result, ...source.payload }), {}, @@ -271,18 +275,18 @@ export class CatalogEnrichmentService { dataRevision: dependencies .map((dependency) => dependency.dataRevision) .join('/'), - availability: !first.instrument - ? 'missing' - : complete - ? 'complete' - : 'partial', - fetchedAt: first.meta.lastSuccessfulAt - ? new Date(first.meta.lastSuccessfulAt) - : null, + availability: !first.instrument ? + 'missing' : + complete ? + 'complete' : + 'partial', + fetchedAt: first.meta.lastSuccessfulAt ? + new Date(first.meta.lastSuccessfulAt) : + null, freshForMs: - section === 'company' - ? tbankSourceTtlMs.company - : tbankSourceTtlMs.quote, + section === 'company' ? + tbankSourceTtlMs.company : + tbankSourceTtlMs.quote, work: null, dependencies, }); @@ -307,18 +311,21 @@ export class CatalogEnrichmentService { ): Promise { if (input.kind === 'detail' || input.kind === 'bond-current') { const instrument = - input.instrumentType === 'share' - ? await this.tbank.getShare(token, input.uid) - : await this.tbank.getBond(token, input.uid); - if (instrument && instrument.uid !== input.uid) + input.instrumentType === 'share' ? + await this.tbank.getShare(token, input.uid) : + await this.tbank.getBond(token, input.uid); + if (instrument && instrument.uid !== input.uid) { throw new Error('Catalog UID mismatch'); + } + // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. if (instrument === undefined) throw new Error('Missing catalog response'); return { instrument: instrument ? publicInstrument(instrument) : null }; } - if (input.kind === 'trading-status') + if (input.kind === 'trading-status') { return { tradingStatus: await this.tbank.getTradingStatus(token, input.uid), }; + } if (input.kind === 'comparison') { const prices = await this.repository.readSources([ { ...input, kind: 'last-price', rangeKey: undefined }, @@ -343,16 +350,18 @@ export class CatalogEnrichmentService { ); const rows = response.map((candle) => { const time = timestamp(candle.time); - if (!time || !candle.close) + if (!time || !candle.close) { throw new Error('Invalid candle response'); + } return { time, close: formatDecimal(decimal(candle.close)), isComplete: candle.isComplete === true, }; }); - if (!(await this.candles.stage(attempt, gap, rows, now))) + if (!(await this.candles.stage(attempt, gap, rows, now))) { throw new Error('Cancelled candle comparison'); + } } if ( gaps.length && @@ -361,8 +370,9 @@ export class CatalogEnrichmentService { { uid: input.uid, ranges: gaps }, false, )) - ) + ) { throw new Error('Cancelled candle comparison'); + } const values = (await this.candles.read(input.uid, range)).data.map( (row) => { const value = divide( @@ -386,48 +396,51 @@ export class CatalogEnrichmentService { } const details = await this.repository.readSources([ { ...input, kind: 'detail', rangeKey: undefined }, - ...(input.instrumentType === 'bond' - ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] - : []), + ...(input.instrumentType === 'bond' ? + [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : + []), ]); const instrument = details .map((row) => (row.payload as CatalogPayload).instrument) .find(Boolean); if ( !instrument && - !['dividends', 'coupons', 'bond-events'].includes(input.kind) - ) + !['bond-events', 'coupons', 'dividends'].includes(input.kind) + ) { throw new Error('Instrument dependency missing'); - if (input.kind === 'description') + } + if (input.kind === 'description') { return { - description: instrument?.assetUid - ? await this.tbank.getAssetDescription(token, instrument?.assetUid) - : null, + description: instrument?.assetUid ? + await this.tbank.getAssetDescription(token, instrument?.assetUid) : + null, }; - if (input.kind === 'fundamentals') + } + if (input.kind === 'fundamentals') { return { - fundamentals: instrument?.assetUid - ? await this.tbank.getFundamentals(token, instrument?.assetUid) - : null, + fundamentals: instrument?.assetUid ? + await this.tbank.getFundamentals(token, instrument?.assetUid) : + null, }; + } if (input.kind === 'coupons' || input.kind === 'dividends') { const year = Number(input.rangeKey); - const [from, to] = input.rangeKey?.startsWith('[') - ? (JSON.parse(input.rangeKey) as [string, string]) - : [ + const [from, to] = input.rangeKey?.startsWith('[') ? + (JSON.parse(input.rangeKey) as [string, string]) : + [ `${year - payoutHistoryYears}-01-01`, `${year + payoutFutureYears}-01-01`, ]; - return input.kind === 'coupons' - ? { + return input.kind === 'coupons' ? + { coupons: await this.tbank.getBondCoupons( token, input.uid, from, to, ), - } - : { + } : + { dividends: await this.tbank.getDividends( token, input.uid, @@ -441,23 +454,25 @@ export class CatalogEnrichmentService { if (!ticker) throw new Error('Bond ticker missing'); return { bondEvents: await this.moex.bondEvents(ticker) }; } - if (input.kind === 'yield') + if (input.kind === 'yield') { return { effectiveYield: - instrument?.ticker && instrument.isin && instrument.classCode - ? await this.moex.bondYield( + instrument?.ticker && instrument.isin && instrument.classCode ? + await this.moex.bondYield( instrument.ticker, instrument.isin, instrument.classCode, - ) - : null, + ) : + null, }; - if (input.kind === 'rating') + } + if (input.kind === 'rating') { return { - creditRating: instrument?.isin - ? await this.ratings.rating(instrument.isin) - : null, + creditRating: instrument?.isin ? + await this.ratings.rating(instrument.isin) : + null, }; + } throw new Error('Unsupported catalog source'); } @@ -473,6 +488,7 @@ export class CatalogEnrichmentService { let i = 0; i < this.queue.length && batch.length < catalogSearchLimit; ) { + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. const next = this.queue[i]; if ( next.input.kind === 'last-price' && @@ -490,38 +506,40 @@ export class CatalogEnrichmentService { try { const token = await this.access.tokenForAttempt(producer.attempt); if (!token) failed = true; - else if (producer.input.kind === 'last-price') + else if (producer.input.kind === 'last-price') { prices = await this.tbank.getLastPrices( token, batch.map((item) => item.input.uid), ); - else + } else { payload = await this.load(producer.input, token, producer.attempt); + } } catch (error) { failed = true; - if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') + if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { await this.access.rejectMembershipToken(producer.attempt); - else + } else { this.logger.event('warn', 'catalog-enrichment.failed', { code: 'TBANK_UNAVAILABLE', }); + } } for (const item of batch) { const price = prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? null; const value = - item.input.kind === 'last-price' - ? { - price: price - ? { + item.input.kind === 'last-price' ? + { + price: price ? + { instrumentUid: item.input.uid, price: price.price, time: price.time, - } - : null, - } - : payload; + } : + null, + } : + payload; // Keep accepting observers while publication awaits SQLite. item.observers.unshift({ attempt: item.attempt, input: item.input }); for (const observer of item.observers) { @@ -558,14 +576,14 @@ export class CatalogEnrichmentService { } } function sourceInputs( - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', uids: string[], kinds: CatalogSource['kind'][], range?: { from: string; - to: string; ticker?: string; tickers?: Record; + to: string; }, ): CatalogSource[] { return kinds.flatMap((kind) => @@ -573,20 +591,22 @@ function sourceInputs( instrumentType, uid, kind, - ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) - ? { ticker: range?.tickers?.[uid] || range?.ticker } - : {}), - ...(['coupons', 'dividends'].includes(kind) - ? { - rangeKey: range - ? JSON.stringify([range.from, range.to]) - : String(new Date().getUTCFullYear()), - } - : {}), + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? + // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. + { ticker: range?.tickers?.[uid] || range?.ticker } : + {}), + ...(['coupons', 'dividends'].includes(kind) ? + { + rangeKey: range ? + JSON.stringify([range.from, range.to]) : + String(new Date().getUTCFullYear()), + } : + {}), })), ); } -function publicInstrument(value: TbankShare | TbankBond) { +function publicInstrument(value: TbankBond | TbankShare) { const bond = value as TbankBond; return { uid: value.uid, @@ -625,38 +645,39 @@ const previousCloseLookbackMs = const payoutHistoryYears = 5; const payoutFutureYears = 2; export type CatalogPayload = { - instrument?: TbankShare | TbankBond | null; - price?: TbankLastPrice | null; - candle?: TbankCandle | null; - tradingStatus?: string | null; - description?: string | null; - fundamentals?: TbankFundamentals | null; - coupons?: TbankCoupon[]; - dividends?: TbankDividend[]; bondEvents?: MoexBondEvent[]; - effectiveYield?: Awaited>; + candle?: null | TbankCandle; + coupons?: TbankCoupon[]; creditRating?: Awaited>; + description?: null | string; + dividends?: TbankDividend[]; + effectiveYield?: Awaited>; + fundamentals?: null | TbankFundamentals; + instrument?: null | TbankBond | TbankShare; + price?: null | TbankLastPrice; + tradingStatus?: null | string; }; function sourcePolicy(kind: CatalogSource['kind']) { if ( [ - 'last-price', 'bond-current', - 'trading-status', 'comparison', + 'last-price', + 'trading-status', 'yield', ].includes(kind) - ) + ) { return 'quote'; + } if (kind === 'description' || kind === 'fundamentals') return 'company'; - if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar'; + if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar'; return 'metadata'; } function producerKey(input: CatalogSource) { return input.instrumentType === 'bond' && - (input.kind === 'detail' || input.kind === 'bond-current') - ? `bond-metadata:${input.uid}` - : catalogScope(input); + (input.kind === 'detail' || input.kind === 'bond-current') ? + `bond-metadata:${input.uid}` : + catalogScope(input); } const quotationScale = 9; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 0f39396..c91f26a 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -5,13 +5,13 @@ export const catalogSearchLimit = 30; export function selectCatalogInstruments( found: T[], query: string, - instrumentType: 'share' | 'bond', + instrumentType: 'bond' | 'share', ): (T & { uid: string })[] { const seen = new Set(); const kind = - instrumentType === 'share' - ? 'INSTRUMENT_TYPE_SHARE' - : 'INSTRUMENT_TYPE_BOND'; + instrumentType === 'share' ? + 'INSTRUMENT_TYPE_SHARE' : + 'INSTRUMENT_TYPE_BOND'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; return found .filter( @@ -21,17 +21,17 @@ export function selectCatalogInstruments( .sort( (a, b) => Number(b.apiTradeAvailableFlag === true) - - Number(a.apiTradeAvailableFlag === true) || + Number(a.apiTradeAvailableFlag === true) || Number(b.ticker?.toUpperCase() === query.toUpperCase()) - - Number(a.ticker?.toUpperCase() === query.toUpperCase()) || + Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(b.classCode === preferredClass) - - Number(a.classCode === preferredClass) || + Number(a.classCode === preferredClass) || a.uid.localeCompare(b.uid), ) .filter((instrument) => { - const key = instrument.isin?.trim() - ? `isin:${instrument.isin.trim().toUpperCase()}` - : `uid:${instrument.uid}`; + const key = instrument.isin?.trim() ? + `isin:${instrument.isin.trim().toUpperCase()}` : + `uid:${instrument.uid}`; if (seen.has(key)) return false; seen.add(key); return true; diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index b09f461..a43b3c3 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -1,15 +1,16 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import type { TbankCandle, TbankTimestamp, } from '../../../../integrations/tbank/tbank.api'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + const MOSCOW_OFFSET_MS = 10_800_000; -export function timestamp(value?: TbankTimestamp): string | null { +export function timestamp(value?: TbankTimestamp): null | string { const seconds = Number(value?.seconds); - return Number.isFinite(seconds) && seconds > 0 - ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() - : null; + return Number.isFinite(seconds) && seconds > 0 ? + new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : + null; } export function moscowDay(iso: string): string { return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) @@ -19,7 +20,7 @@ export function moscowDay(iso: string): string { export function previousClose( candles: TbankCandle[], priceAt: string, -): TbankCandle | null { +): null | TbankCandle { return ( candles .filter((candle) => { @@ -32,6 +33,6 @@ export function previousClose( ); }) .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? - null + null ); } diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 2c84ff4..e808f8c 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -5,7 +5,6 @@ import { formatDecimal, multiply, } from '../../../../common/decimal'; - import { MoneyDto } from '../../../../common/money.dto'; import { PortfolioDto, @@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6; export function instrumentPosition( portfolio: PortfolioDto, uid: string, - type: 'STOCK' | 'BOND', + type: 'BOND' | 'STOCK', ): StockPositionDto { const positions = portfolio.positions.filter( (p) => p.type === type && p.instrumentUid === uid, @@ -40,8 +39,8 @@ export function instrumentPosition( currentValue, absoluteReturn, averagePurchasePrice: - positive && totalCost - ? { + positive && totalCost ? + { currency: totalCost.currency, value: formatDecimal( divide( @@ -50,15 +49,15 @@ export function instrumentPosition( PRICE_DECIMAL_PLACES, ), ), - } - : null, + } : + null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null, accounts: positions.map((p) => ({ ...p, returnPercent: - decimalFromString(p.quantity).value > 0n - ? percent(p.absoluteReturn, cost(p)) - : null, + decimalFromString(p.quantity).value > 0n ? + percent(p.absoluteReturn, cost(p)) : + null, })), isStale: portfolio.isStale, observedAt: portfolio.lastSuccessfulAt, @@ -66,8 +65,8 @@ export function instrumentPosition( } function cost(position: PortfolioPositionDto): MoneyDto | null { const price = position.averagePurchasePrice; - return price - ? { + return price ? + { currency: price.currency, value: formatDecimal( multiply( @@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null { decimalFromString(position.quantity), ), ), - } - : null; + } : + null; } function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { const currency = values[0]?.currency; if ( !currency || !values.every((v): v is MoneyDto => v !== null && v.currency === currency) - ) + ) { return null; + } return { currency, value: formatDecimal( @@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function percent( result: MoneyDto | null, base: MoneyDto | null, -): string | null { +): null | string { if ( !base || !result || base.currency !== result.currency || decimalFromString(base.value).value <= 0n - ) + ) { return null; + } return formatDecimal( divide( multiply(decimalFromString(result.value), decimalFromString('100')), diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index b8cd052..0ee3d2b 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { BondDto, @@ -32,23 +34,24 @@ export class BondCatalogService { data: result.data?.map((item) => { const short = toBond(item); - const detail = item.detail - ? toBond({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + toBond({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, classCode: detail.classCode ?? short.classCode, isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, - pricePercent: last?.price - ? formatDecimal(decimal(last.price)) - : null, + pricePercent: last?.price ? + formatDecimal(decimal(last.price)) : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -57,11 +60,12 @@ export class BondCatalogService { const result = await this.discovery.detail(userId, 'bond', uid, observe); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -75,9 +79,9 @@ export class BondCatalogService { ); return { ...result, - data: result.data - ? { ...toBond(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...toBond(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 7e82d38..a9f4869 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -1,18 +1,19 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, + divide, formatDecimal, multiply, - divide, } from '../../../../../common/decimal'; import { TbankAccessService, - type TbankCoupon, type TbankBond, + type TbankCoupon, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService, @@ -20,14 +21,14 @@ import { } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCouponsDto, BondCouponsResourceDto, - BondPositionDto, BondMetricsDto, BondMetricsResourceDto, + BondPositionDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; import { bondQuote } from './bond-market.service'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @@ -40,6 +41,7 @@ export class BondIncomeService { private readonly access: TbankAccessService, private readonly forecasts: ForecastService, ) {} + async metrics( userId: string, uid: string, @@ -53,9 +55,9 @@ export class BondIncomeService { observe, ); return { - data: result.values.instrument - ? bondMetrics(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondMetrics(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } @@ -67,6 +69,7 @@ export class BondIncomeService { meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, }; } + async coupons( userId: string, uid: string, @@ -85,21 +88,22 @@ export class BondIncomeService { ['detail', 'coupons'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.coupons; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'BOND') - : null; - const projected = events - ? bondCoupons( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'BOND') : + null; + const projected = events ? + bondCoupons( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -110,9 +114,9 @@ export class BondIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: bondCoupons( events ?? [], null, @@ -135,19 +139,19 @@ export function bondCoupons( const { from, to, today } = window; const quantity = position?.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const coupons = events .map((event) => { const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const perBond = money(event.payOneBond); - const timing = !paymentDate - ? ('undated' as const) - : paymentDate >= today - ? ('upcoming' as const) - : ('past' as const); + const timing = !paymentDate ? + ('undated' as const) : + paymentDate >= today ? + ('upcoming' as const) : + ('past' as const); return { number: event.couponNumber ?? null, paymentDate, @@ -161,14 +165,14 @@ export function bondCoupons( perBond && timing === 'upcoming' && recordDate && - recordDate >= today - ? { + recordDate >= today ? + { currency: perBond.currency, value: formatDecimal( multiply(quantity, decimalFromString(perBond.value)), ), - } - : null, + } : + null, }; }) .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); @@ -214,8 +218,8 @@ export function bondMetrics( clean && coupon.currency === clean.currency && decimalFromString(coupon.value).value >= 0n && - decimalFromString(clean.value).value > 0n - ? formatDecimal( + decimalFromString(clean.value).value > 0n ? + formatDecimal( divide( multiply( decimalFromString(coupon.value), @@ -227,14 +231,14 @@ export function bondMetrics( ), COUPON_YIELD_DECIMAL_PLACES, ), - ) - : null; + ) : + null; const couponType = - next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' - ? next.couponType - : instrument.floatingCouponFlag === true - ? 'COUPON_TYPE_FLOATING' - : null; + next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? + next.couponType : + instrument.floatingCouponFlag === true ? + 'COUPON_TYPE_FLOATING' : + null; return { creditRating, coupon, diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index 6365538..c12b5b8 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -1,10 +1,7 @@ -import { CandleService } from '../../application/candle.service'; -import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + +import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; + import { add, decimalFromString, @@ -14,15 +11,19 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; +import { money } from '../infrastructure/bond-money.mapper'; import { BondCandlesQueryDto, BondQuoteDto, BondQuoteResourceDto, } from '../presentation/bond.dto'; -import { money } from '../infrastructure/bond-money.mapper'; - const PRICE_DECIMAL_PLACES = 9; @Injectable() export class BondMarketService { @@ -30,6 +31,7 @@ export class BondMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -43,12 +45,13 @@ export class BondMarketService { observe, ); return { - data: result.values.instrument - ? bondQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + bondQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -77,8 +80,8 @@ export function bondQuote( const cleanPrice = pricePercent !== null && nominal && - decimalFromString(nominal.value).value > 0n - ? { + decimalFromString(nominal.value).value > 0n ? + { currency: nominal.currency, value: formatDecimal( divide( @@ -90,11 +93,11 @@ export function bondQuote( PRICE_DECIMAL_PLACES, ), ), - } - : null; + } : + null; const dirtyPrice = - cleanPrice && aci && aci.currency === cleanPrice.currency - ? { + cleanPrice && aci && aci.currency === cleanPrice.currency ? + { currency: cleanPrice.currency, value: formatDecimal( add( @@ -102,8 +105,8 @@ export function bondQuote( decimalFromString(aci.value), ), ), - } - : null; + } : + null; return { pricePercent, cleanPrice, @@ -112,9 +115,9 @@ export function bondQuote( priceAt, tradingStatus, changePercentagePoints: - last?.price && previous?.close - ? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) - : null, + last?.price && previous?.close ? + formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : + null, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, unavailable, observedAt, diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index 6f8f234..5f9098b 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -1,7 +1,8 @@ +import type { PortfolioDto } from '../../../portfolio/portfolio.api'; + +import { instrumentPosition } from '../../application/instrument-position'; import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondQuote } from './bond-market.service'; -import { instrumentPosition } from '../../application/instrument-position'; -import type { PortfolioDto } from '../../../portfolio/portfolio.api'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); @@ -122,9 +123,9 @@ describe('bond financial projections', () => { { couponDate: time('2026-10-10'), payOneBond: - scenario === 'unknown' - ? undefined - : money('30', scenario === 'currency' ? 'usd' : 'rub'), + scenario === 'unknown' ? + undefined : + money('30', scenario === 'currency' ? 'usd' : 'rub'), couponPeriod: scenario === 'period' ? 0 : 90, }, ], diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index a57dead..861a30b 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -1,10 +1,10 @@ import { formatDecimal } from '../../../../../common/decimal'; -import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; export function money(value?: TbankMoney) { const currency = value?.currency?.toUpperCase(); - return value && currency - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return value && currency ? + { currency, value: formatDecimal(decimal(value)) } : + null; } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index da9bdf7..1675bbb 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,24 +10,25 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import { BondCatalogService } from '../application/bond-catalog.service'; import { BondIncomeService } from '../application/bond-income.service'; import { BondMarketService } from '../application/bond-market.service'; import { - BondCandlesResourceDto, BondCandlesQueryDto, + BondCandlesResourceDto, BondCouponsResourceDto, - BondResourceDto, BondMetricsResourceDto, BondPositionResourceDto, BondQuoteResourceDto, + BondResourceDto, BondSearchQueryDto, BondSearchResourceDto, BondTickerParamsDto, } from './bond.dto'; -import { BondCatalogService } from '../application/bond-catalog.service'; @ApiTags('bonds') @ApiProblemResponses() @@ -39,6 +41,7 @@ export class BondController { private readonly market: BondMarketService, private readonly income: BondIncomeService, ) {} + @Get(':uid/metrics') @ApiOkResponse({ type: BondMetricsResourceDto }) metrics( @@ -48,6 +51,7 @@ export class BondController { ) { return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); } + @Get() @ApiOkResponse({ type: BondSearchResourceDto }) search( @@ -60,6 +64,7 @@ export class BondController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: BondResourceDto }) getByTicker( @@ -73,6 +78,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: BondResourceDto }) get( @@ -82,6 +88,7 @@ export class BondController { ) { return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: BondQuoteResourceDto }) quote( @@ -91,6 +98,7 @@ export class BondController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: BondCandlesResourceDto }) candles( @@ -105,6 +113,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: BondPositionResourceDto }) position( @@ -118,6 +127,7 @@ export class BondController { query.sync === 'observe', ); } + @Get(':uid/coupons') @ApiOkResponse({ type: BondCouponsResourceDto }) coupons( diff --git a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts index 009beb8..a7624c1 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -1,7 +1,7 @@ -import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { ApiProperty } from '@nestjs/swagger'; -import { MoneyDto } from '../../../../../common/money.dto'; +import { MoneyDto } from '../../../../../common/money.dto'; +import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { StockCandlesDto, StockCandlesQueryDto, @@ -20,44 +20,61 @@ export class BondDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare nominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare initialNominal: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) declare maturityDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare callDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare bondType: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare riskLevel: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear: - number | null; + null | number; + @ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare perpetual: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare amortization: boolean | null; + @ApiProperty({ type: Boolean, nullable: true }) declare subordinated: boolean | null; } @@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto { nullable: true, description: 'Clean price in percent of nominal, not money.', }) - declare pricePercent: string | null; + declare pricePercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class BondQuoteDto { @ApiProperty({ type: String, nullable: true }) declare pricePercent: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true, @@ -82,6 +101,7 @@ export class BondQuoteDto { 'Indicative clean value at the current nominal; no FX conversion.', }) declare cleanPrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, @@ -90,41 +110,53 @@ export class BondQuoteDto { 'Indicative clean value plus current accrued interest, excluding fees.', }) declare dirtyPrice: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) - declare changePercentagePoints: string | null; + declare changePercentagePoints: null | string; + @ApiProperty({ type: String, nullable: true }) declare previousCloseDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + declare priceAt: null | string; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondCouponDto { - @ApiProperty({ type: String, nullable: true }) declare number: string | null; + @ApiProperty({ type: String, nullable: true }) declare number: null | string; @ApiProperty({ type: String, nullable: true }) declare paymentDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare recordDate: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare periodDays: - number | null; + null | number; + @ApiProperty({ type: MoneyDto, nullable: true }) declare perBond: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; } export class BondCouponsDto { - @ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[]; + @ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[]; @ApiProperty() declare from: string; @ApiProperty() declare to: string; @ApiProperty() declare observedAt: string; @ApiProperty({ type: String, nullable: true }) declare positionAt: - string | null; + null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } @@ -132,6 +164,7 @@ export class BondCouponsDto { export class BondYieldDto { @ApiProperty({ description: 'MOEX effective annual yield in percent.' }) declare value: string; + @ApiProperty() declare pricePercent: string; @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ @@ -139,14 +172,16 @@ export class BondYieldDto { nullable: true, description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', }) - declare dateType: string | null; + declare dateType: null | string; + @ApiProperty({ format: 'date-time' }) declare tradeAt: string; } export class BondCreditRatingDto { @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR'; - @ApiProperty({ type: String, nullable: true }) declare value: string | null; + @ApiProperty({ type: String, nullable: true }) declare value: null | string; @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status: 'active' | 'withdrawn'; + @ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ format: 'uri' }) declare sourceUrl: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @@ -154,39 +189,50 @@ export class BondCreditRatingDto { export class BondMetricsDto { @ApiProperty({ type: BondCreditRatingDto, nullable: true }) declare creditRating: BondCreditRatingDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare coupon: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare couponDate: string | null; + declare couponDate: null | string; + @ApiProperty({ type: String, nullable: true }) declare couponType: - string | null; + null | string; + @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays: - number | null; + null | number; + @ApiProperty({ type: String, nullable: true, description: 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', }) - declare couponYieldPercent: string | null; + declare couponYieldPercent: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare couponYieldPriceAt: string | null; + declare couponYieldPriceAt: null | string; + @ApiProperty({ type: BondYieldDto, nullable: true }) declare effectiveYield: BondYieldDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class BondPositionResourceDto { @ApiProperty({ type: BondPositionDto, nullable: true }) declare data: BondPositionDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class BondSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true }) declare data: BondSearchResultDto[] | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -200,6 +246,7 @@ export class BondResourceDto { export class BondQuoteResourceDto { @ApiProperty({ type: BondQuoteDto, nullable: true }) declare data: BondQuoteDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -207,6 +254,7 @@ export class BondQuoteResourceDto { export class BondMetricsResourceDto { @ApiProperty({ type: BondMetricsDto, nullable: true }) declare data: BondMetricsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -214,6 +262,7 @@ export class BondMetricsResourceDto { export class BondCandlesResourceDto { @ApiProperty({ type: BondCandlesDto, nullable: true }) declare data: BondCandlesDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } @@ -221,6 +270,7 @@ export class BondCandlesResourceDto { export class BondCouponsResourceDto { @ApiProperty({ type: BondCouponsDto, nullable: true }) declare data: BondCouponsDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/instruments/domain/candle-coverage.spec.ts b/src/modules/investments/instruments/domain/candle-coverage.spec.ts index 549051a..b2915df 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.spec.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.spec.ts @@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => { expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); - if (index) expect(window.from).toEqual(windows[index - 1].to); + expect(window.from).toEqual(index ? windows[index - 1].to : range.from); }); }); }); diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index d4302c9..91e9c5d 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time'; export type CandleRange = { from: Date; to: Date }; export type CandleCoverage = CandleRange & { - isComplete: boolean; fetchedAt: Date; + isComplete: boolean; }; export type SavedCandle = { - time: string; close: string; isComplete: boolean; + time: string; }; const windowDays = 365; const tailFreshForMs = 60_000; @@ -30,14 +30,15 @@ export function candleGaps( !covered.isComplete && (now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs || utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf()) - ) + ) { continue; + } gaps = gaps.flatMap((gap) => { if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; return [ - ...(covered.from > gap.from - ? [{ from: gap.from, to: covered.from }] - : []), + ...(covered.from > gap.from ? + [{ from: gap.from, to: covered.from }] : + []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ]; }); @@ -73,11 +74,14 @@ export function confirmedCandleCoverage( for (const boundary of [ midnight, ...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]), - ]) - if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) + ]) { + if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) { boundaries.add(boundary); + } + } const sorted = [...boundaries].sort((a, b) => a - b); return sorted.slice(1).map((to, index) => { + // eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array. const from = sorted[index]; return { from: new Date(from), diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index d3c4273..8363bde 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -1,24 +1,25 @@ import { Injectable } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - confirmedCandleCoverage, type CandleRange, + confirmedCandleCoverage, type SavedCandle, } from '../domain/candle-coverage'; const provider = 'tbank'; const interval = 'day'; type CandleIntent = { - instrumentType?: 'share' | 'bond'; - uid: string; - ranges: CandleRange[]; - period?: string; - now?: string; force?: boolean; + instrumentType?: 'bond' | 'share'; + now?: string; + period?: string; + ranges: CandleRange[]; + uid: string; }; @Injectable() export class CandleRepository { @@ -57,7 +58,7 @@ export class CandleRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, scopeKey: string, intent: CandleIntent, runId: string, @@ -88,29 +89,31 @@ export class CandleRepository { (!intent.force || (previousIntent.force === true && (!intent.now || intent.now <= previousNow))) - ) + ) { return { work: previous, launch: false }; + } const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { priority: nextPriority, intent: { ...previousIntent, - ...(intent.force - ? { + ...(intent.force ? + { force: true, - ...(intent.now && intent.now > previousNow - ? { now: intent.now } - : {}), - } - : {}), + ...(intent.now && intent.now > previousNow ? + { now: intent.now } : + {}), + } : + {}), }, }, }); return { work, launch: false }; } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -142,12 +145,15 @@ export class CandleRepository { fetchedAt: Date, ) { // Reject malformed financial input instead of publishing a false empty range. - for (const candle of candles) + for (const candle of candles) { if ( !Number.isFinite(Date.parse(candle.time)) || + // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear. !/^-?\d+(?:\.\d+)?$/.test(candle.close) - ) + ) { throw new Error('Invalid candle'); + } + } const key = `${range.from.toISOString()}/${range.to.toISOString()}`; const rows = [ ...new Map( @@ -155,7 +161,7 @@ export class CandleRepository { .filter( (candle) => new Date(candle.time) >= range.from && - new Date(candle.time) < range.to, + new Date(candle.time) < range.to, ) .map((candle) => [candle.time, candle]), ).values(), @@ -222,8 +228,9 @@ export class CandleRepository { `${range.from.toISOString()}/${range.to.toISOString()}`, ), ) - ) + ) { throw new Error('Incomplete candle reconciliation'); + } const identity = { provider, instrumentUid: intent.uid, interval }; for (const range of intent.ranges) { const row = staged.find( @@ -254,21 +261,22 @@ export class CandleRepository { // Preserve coverage outside the requested reconciliation boundaries. for (const previous of old) { for (const part of [ - ...(previous.from < range.from - ? [{ from: previous.from, to: range.from }] - : []), - ...(previous.to > range.to - ? [{ from: range.to, to: previous.to }] - : []), - ]) + ...(previous.from < range.from ? + [{ from: previous.from, to: range.from }] : + []), + ...(previous.to > range.to ? + [{ from: range.to, to: previous.to }] : + []), + ]) { await tx.tbankCandleCoverage.create({ data: { ...previous, ...part }, }); + } } await tx.tbankCandle.deleteMany({ where: { ...identity, time: { gte: range.from, lt: range.to } }, }); - for (const candle of payload.candles) + for (const candle of payload.candles) { await tx.tbankCandle.create({ data: { ...identity, @@ -279,14 +287,16 @@ export class CandleRepository { fetchedAt, }, }); + } for (const covered of confirmedCandleCoverage( range, payload.candles, fetchedAt, - )) + )) { await tx.tbankCandleCoverage.create({ data: { ...identity, ...covered }, }); + } } await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, attemptId: attempt.attemptId }, diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 2c2d183..ae844f1 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -1,40 +1,42 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; import type { TbankBond, TbankShare, TbankSyncAttempt, } from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + export type CatalogQuery = { - instrumentType: 'share' | 'bond'; - query: string; exactTicker?: boolean; + instrumentType: 'bond' | 'share'; + query: string; }; export type CatalogSource = { - instrumentType: 'share' | 'bond'; - uid: string; + instrumentType: 'bond' | 'share'; kind: - | 'detail' - | 'last-price' - | 'bond-current' - | 'description' - | 'fundamentals' - | 'trading-status' - | 'comparison' - | 'coupons' - | 'yield' - | 'rating' - | 'dividends' - | 'bond-events'; + | 'bond-current' | + 'bond-events' | + 'comparison' | + 'coupons' | + 'description' | + 'detail' | + 'dividends' | + 'fundamentals' | + 'last-price' | + 'rating' | + 'trading-status' | + 'yield'; rangeKey?: string; ticker?: string; + uid: string; }; export type CatalogWork = CatalogQuery | CatalogSource; -export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string }; +export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string }; const provider = 'tbank'; export function catalogQuery(input: CatalogQuery): CatalogQuery { return { @@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery { }; } export function catalogScope(input: CatalogWork) { - if ('uid' in input) + if ('uid' in input) { return `catalog-source:${JSON.stringify([ input.kind, input.kind === 'detail' ? input.instrumentType : null, input.rangeKey ?? '', input.uid, ])}`; + } return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`; } @@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository { read(input: CatalogQuery) { return this.prisma.$transaction(async (tx) => { - const alias = input.exactTicker - ? await tx.tbankInstrumentLookup.findUnique({ + const alias = input.exactTicker ? + await tx.tbankInstrumentLookup.findUnique({ where: { provider_identifierType_identifier: { provider, @@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository { identifier: input.query, }, }, - }) - : null; - const discovery = input.exactTicker - ? alias - ? { + }) : + null; + const discovery = input.exactTicker ? + alias ? + { instrumentUids: alias.matchedUids, fetchedAt: alias.fetchedAt, dataRevision: alias.dataRevision, - } - : null - : await tx.tbankCatalogDiscovery.findUnique({ + } : + null : + await tx.tbankCatalogDiscovery.findUnique({ where: { provider_instrumentType_query: { provider, @@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository { }); const byUid = new Map(rows.map((row) => [row.instrumentUid, row])); // A damaged/missing dependency is unknown, never a confirmed empty search. - if (uids.some((uid) => !byUid.has(uid))) + if (uids.some((uid) => !byUid.has(uid))) { return { data: null, fetchedAt: null, dataRevision: String(discovery.dataRevision), }; + } return { data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument), fetchedAt: discovery.fetchedAt, @@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository { kind: input.kind, source: sourceProvider(input), rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), })), }, }); @@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository { }, }); const instrument = ( - value as { instrument?: TbankShare | TbankBond | null } + value as { instrument?: null | TbankBond | TbankShare } ).instrument; if ( (input.kind === 'detail' || input.kind === 'bond-current') && @@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previousAlias?.matchedUids, matchedUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository { source: sourceProvider(input), kind: input.kind, rangeKey: - input.kind === 'detail' - ? input.instrumentType - : (input.rangeKey ?? ''), + input.kind === 'detail' ? + input.instrumentType : + (input.rangeKey ?? ''), }; const previous = await tx.tbankInstrumentSource.findUnique({ where: { provider_instrumentUid_source_kind_rangeKey: key }, @@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository { } reserve( - owner: { userId: string; revision: string }, + owner: { revision: string; userId: string }, input: CatalogWork, runId: string, active: ReadonlySet, @@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository { previous?.state === 'running' && previous.processRunId === runId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { attemptId: randomUUID(), processRunId: runId, @@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository { instrumentKind: instrument.instrumentKind, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, currency: instrument.currency, - brand: instrument.brand - ? { logoName: instrument.brand.logoName } - : undefined, + brand: instrument.brand ? + { logoName: instrument.brand.logoName } : + undefined, }), ) as Prisma.InputJsonObject; await tx.tbankInstrument.upsert({ @@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.matchedUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository { increment: isDeepStrictEqual( previous?.instrumentUids, instrumentUids, - ) - ? 0 - : 1, + ) ? + 0 : + 1, }, }, }); @@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository { } function sourceProvider(input: CatalogSource) { - return input.kind === 'yield' || input.kind === 'bond-events' - ? 'moex' - : input.kind === 'rating' - ? 'ncr' - : 'tbank'; + return input.kind === 'yield' || input.kind === 'bond-events' ? + 'moex' : + input.kind === 'rating' ? + 'ncr' : + 'tbank'; } diff --git a/src/modules/investments/instruments/instrument-income.api.ts b/src/modules/investments/instruments/instrument-income.api.ts index b717fd0..8b01151 100644 --- a/src/modules/investments/instruments/instrument-income.api.ts +++ b/src/modules/investments/instruments/instrument-income.api.ts @@ -1,2 +1,2 @@ -export { StockIncomeService } from './stocks/application/stock-income.service'; export { BondIncomeService } from './bonds/application/bond-income.service'; +export { StockIncomeService } from './stocks/application/stock-income.service'; diff --git a/src/modules/investments/instruments/instrument-sources.module.ts b/src/modules/investments/instruments/instrument-sources.module.ts index 24a85ef..23e20cf 100644 --- a/src/modules/investments/instruments/instrument-sources.module.ts +++ b/src/modules/investments/instruments/instrument-sources.module.ts @@ -1,11 +1,12 @@ import { Module } from '@nestjs/common'; + import { MoexModule } from '../../../integrations/moex/moex.module'; import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { CandleService } from './application/candle.service'; -import { CandleRepository } from './infrastructure/candle.repository'; -import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; import { CatalogDiscoveryService } from './application/catalog-discovery.service'; +import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; +import { CandleRepository } from './infrastructure/candle.repository'; import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository'; @Module({ diff --git a/src/modules/investments/instruments/instruments.api.ts b/src/modules/investments/instruments/instruments.api.ts index bc88c67..014d610 100644 --- a/src/modules/investments/instruments/instruments.api.ts +++ b/src/modules/investments/instruments/instruments.api.ts @@ -1,5 +1,5 @@ export { CandleService } from './application/candle.service'; -export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; -export type { CatalogPayload } from './application/catalog-enrichment.service'; - export { CatalogDiscoveryService } from './application/catalog-discovery.service'; +export { CatalogEnrichmentService } from './application/catalog-enrichment.service'; + +export type { CatalogPayload } from './application/catalog-enrichment.service'; diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index 8eb5280..9ba931e 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,17 +1,18 @@ -import { InstrumentSourcesModule } from './instrument-sources.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; +import { BondCatalogService } from './bonds/application/bond-catalog.service'; import { BondIncomeService } from './bonds/application/bond-income.service'; import { BondMarketService } from './bonds/application/bond-market.service'; import { BondController } from './bonds/presentation/bond.controller'; -import { BondCatalogService } from './bonds/application/bond-catalog.service'; +import { InstrumentSourcesModule } from './instrument-sources.module'; import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case'; +import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockMarketService } from './stocks/application/stock-market.service'; import { StockController } from './stocks/presentation/stock.controller'; -import { StockCatalogService } from './stocks/application/stock-catalog.service'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index 8c05cdb..ea4df48 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -1,5 +1,7 @@ import { Injectable } from '@nestjs/common'; + import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; + import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { StockCompanyResourceDto, @@ -21,8 +23,8 @@ export class GetStockCompanyUseCase { observe, ); return { - data: result.values.instrument - ? { + data: result.values.instrument ? + { description: result.values.description ?? null, metrics: metrics( result.values.fundamentals ?? null, @@ -30,14 +32,14 @@ export class GetStockCompanyUseCase { ), unavailable: result.unavailable, observedAt: result.observedAt, - } - : null, + } : + null, meta: result.meta, }; } } export function metrics( - values: TbankFundamentals | null, + values: null | TbankFundamentals, sector?: string, ): StockMetricDto[] { if (!values) return []; @@ -49,30 +51,31 @@ export function metrics( ][] = [ ['marketCapitalization', 'money', null], ['peRatioTtm', 'ratio', 'TTM'], - ...(bank - ? ([ + ...(bank ? + ([ ['priceToBookTtm', 'ratio', 'TTM'], ['roe', 'percent', null], - ] as typeof selection) - : ([ + ] as typeof selection) : + ([ ['revenueTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'], ] as typeof selection)), ['netIncomeTtm', 'money', 'TTM'], - ...(!bank - ? ([ + ...(!bank ? + ([ ['totalDebtMrq', 'money', 'MRQ'], ['netDebtToEbitda', 'ratio', null], - ] as typeof selection) - : []), + ] as typeof selection) : + []), ]; return selection.flatMap(([key, unit, period]) => { + // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. const value = values[key]; const currency = values.currency?.toUpperCase() || null; return typeof value === 'number' && Number.isFinite(value) && - (unit !== 'money' || currency) - ? [ + (unit !== 'money' || currency) ? + [ { key, value, @@ -80,7 +83,7 @@ export function metrics( period, currency: unit === 'money' ? currency : null, }, - ] - : []; + ] : + []; }); } diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index 867182c..914d09a 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -1,8 +1,10 @@ -import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { Injectable } from '@nestjs/common'; -import { formatDecimal } from '../../../../../common/decimal'; + import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; + +import { formatDecimal } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { timestamp } from '../../application/instrument-market'; import { StockDto, @@ -30,9 +32,9 @@ export class StockCatalogService { data: result.data?.map((item) => { const short = stock(item); - const detail = item.detail - ? stock({ ...item.detail, uid: item.uid }) - : short; + const detail = item.detail ? + stock({ ...item.detail, uid: item.uid }) : + short; const last = item.price; return { ...detail, @@ -40,17 +42,18 @@ export class StockCatalogService { isin: detail.isin ?? short.isin, apiTradeAvailable: short.apiTradeAvailable, price: - last?.price && detail.currency - ? { + last?.price && detail.currency ? + { currency: detail.currency, value: formatDecimal(decimal(last.price)), - } - : null, + } : + null, priceAt: timestamp(last?.time), }; }) ?? null, }; } + async get( userId: string, uid: string, @@ -59,11 +62,12 @@ export class StockCatalogService { const result = await this.discovery.detail(userId, 'share', uid, observe); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } + async getByTicker( userId: string, ticker: string, @@ -77,9 +81,9 @@ export class StockCatalogService { ); return { ...result, - data: result.data - ? { ...stock(result.data), slug: result.data.slug } - : null, + data: result.data ? + { ...stock(result.data), slug: result.data.slug } : + null, }; } } diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index a0754a7..9dd4eb2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -1,27 +1,28 @@ import { Injectable, NotFoundException } from '@nestjs/common'; + import { decimalFromString, formatDecimal, multiply, } from '../../../../../common/decimal'; +import { decimal } from '../../../../../integrations/tbank/decimal'; import { TbankAccessService, type TbankDividend, } from '../../../../../integrations/tbank/tbank.api'; import { - PortfolioService, ForecastService, + PortfolioService, } from '../../../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { moscowDay, timestamp } from '../../application/instrument-market'; import { instrumentPosition } from '../../application/instrument-position'; import { + StockDividendDto, StockDividendsDto, StockDividendsResourceDto, StockPositionDto, - StockDividendDto, } from '../presentation/stock.dto'; -import { decimal } from '../../../../../integrations/tbank/decimal'; const ISO_YEAR_LENGTH = 4; const INCOME_HISTORY_YEARS = 5; @Injectable() @@ -40,6 +41,7 @@ export class StockIncomeService { meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, }; } + async dividends( userId: string, uid: string, @@ -58,21 +60,22 @@ export class StockIncomeService { ['detail', 'dividends'], { from, to }, ); - if (sources[0].meta.availability === 'complete' && !sources[0].instrument) + if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { throw new NotFoundException(); + } const events = sources[1].payload?.dividends; - const position = portfolio.data - ? instrumentPosition(portfolio.data, uid, 'STOCK') - : null; - const projected = events - ? stockDividends( + const position = portfolio.data ? + instrumentPosition(portfolio.data, uid, 'STOCK') : + null; + const projected = events ? + stockDividends( events, position, { from, to, today }, sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), !portfolio.data, - ) - : undefined; + ) : + undefined; const result = await this.forecasts.result({ userId, revision: connection.revision, @@ -83,9 +86,9 @@ export class StockIncomeService { candidate: portfolio.data && sources[0].instrument ? projected : undefined, }); - return result.data || !projected - ? result - : { + return result.data || !projected ? + result : + { data: stockDividends( events ?? [], null, @@ -100,7 +103,7 @@ export class StockIncomeService { export function stockDividends( events: TbankDividend[], - position: StockPositionDto | null, + position: null | StockPositionDto, window: { from: string; to: string; today: string }, observedAt: string, positionUnavailable: boolean, @@ -109,9 +112,9 @@ export function stockDividends( const quantity = position && position.accounts.length && - position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) - ? decimalFromString(position.quantity) - : null; + position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ? + decimalFromString(position.quantity) : + null; const dividends: StockDividendDto[] = events .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .map((event) => { @@ -120,29 +123,29 @@ export function stockDividends( const lastBuyDate = timestamp(event.lastBuyDate)?.slice(0, 10) ?? null; const currency = event.dividendNet?.currency?.toUpperCase(); const perShare = - currency && event.dividendNet - ? { currency, value: formatDecimal(decimal(event.dividendNet)) } - : null; + currency && event.dividendNet ? + { currency, value: formatDecimal(decimal(event.dividendNet)) } : + null; // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. const eventDate = paymentDate ?? recordDate; - const timing: StockDividendDto['timing'] = eventDate - ? eventDate >= today - ? 'upcoming' - : 'past' - : 'undated'; + const timing: StockDividendDto['timing'] = eventDate ? + eventDate >= today ? + 'upcoming' : + 'past' : + 'undated'; const estimatedAmount = quantity && perShare && recordDate && recordDate >= today && - timing === 'upcoming' - ? { + timing === 'upcoming' ? + { currency: perShare.currency, value: formatDecimal( multiply(quantity, decimalFromString(perShare.value)), ), - } - : null; + } : + null; return { perShare, recordDate, diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index efc6b7c..82dac1d 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -1,9 +1,5 @@ -import { CandleService } from '../../application/candle.service'; -import { - CatalogEnrichmentService, - type CatalogPayload, -} from '../../application/catalog-enrichment.service'; import { Injectable } from '@nestjs/common'; + import { decimalFromString, divide, @@ -12,6 +8,11 @@ import { subtract, } from '../../../../../common/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal'; +import { CandleService } from '../../application/candle.service'; +import { + CatalogEnrichmentService, + type CatalogPayload, +} from '../../application/catalog-enrichment.service'; import { timestamp } from '../../application/instrument-market'; import { StockCandlesQueryDto, @@ -26,6 +27,7 @@ export class StockMarketService { private readonly history: CandleService, private readonly sources: CatalogEnrichmentService, ) {} + async quote( userId: string, uid: string, @@ -39,12 +41,13 @@ export class StockMarketService { observe, ); return { - data: result.values.instrument - ? stockQuote(result.values, result.unavailable, result.observedAt) - : null, + data: result.values.instrument ? + stockQuote(result.values, result.unavailable, result.observedAt) : + null, meta: result.meta, }; } + candles( userId: string, uid: string, @@ -69,20 +72,20 @@ export function stockQuote( const priceAt = timestamp(last?.time); const currency = share?.currency?.toUpperCase(); const price = - last?.price && currency - ? { currency, value: formatDecimal(decimal(last.price)) } - : null; + last?.price && currency ? + { currency, value: formatDecimal(decimal(last.price)) } : + null; const previousPrice = - previous?.close && currency - ? { currency, value: formatDecimal(decimal(previous.close)) } - : null; + previous?.close && currency ? + { currency, value: formatDecimal(decimal(previous.close)) } : + null; const difference = - price && previousPrice - ? subtract( + price && previousPrice ? + subtract( decimalFromString(price.value), decimalFromString(previousPrice.value), - ) - : null; + ) : + null; return { price, priceAt, @@ -90,21 +93,21 @@ export function stockQuote( previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousClose: previousPrice, change: - difference && currency - ? { currency, value: formatDecimal(difference) } - : null, + difference && currency ? + { currency, value: formatDecimal(difference) } : + null, changePercent: difference && previousPrice && - decimalFromString(previousPrice.value).value > 0n - ? formatDecimal( + decimalFromString(previousPrice.value).value > 0n ? + formatDecimal( divide( multiply(difference, decimalFromString('100')), decimalFromString(previousPrice.value), CHANGE_PERCENT_DECIMAL_PLACES, ), - ) - : null, + ) : + null, unavailable, observedAt, }; diff --git a/src/modules/investments/instruments/stocks/application/stock.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts index 2ae894b..8aa115c 100644 --- a/src/modules/investments/instruments/stocks/application/stock.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -1,7 +1,7 @@ +import { previousClose } from '../../application/instrument-market'; import { metrics } from './get-stock-company.use-case'; import { stockDividends } from './stock-income.service'; import { stockQuote } from './stock-market.service'; -import { previousClose } from '../../application/instrument-market'; jest.mock('../../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index aa8dfe7..04c74e4 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -1,4 +1,5 @@ -import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; +import type { Request } from 'express'; + import { Controller, Get, @@ -9,25 +10,26 @@ import { UseGuards, } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; +import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; +import { StockCatalogService } from '../application/stock-catalog.service'; import { StockIncomeService } from '../application/stock-income.service'; import { StockMarketService } from '../application/stock-market.service'; import { - StockCandlesResourceDto, StockCandlesQueryDto, + StockCandlesResourceDto, StockCompanyResourceDto, StockDividendsResourceDto, - StockResourceDto, StockPositionResourceDto, StockQuoteResourceDto, + StockResourceDto, StockSearchQueryDto, StockSearchResourceDto, StockTickerParamsDto, } from './stock.dto'; -import { StockCatalogService } from '../application/stock-catalog.service'; @ApiTags('stocks') @ApiProblemResponses() @@ -41,6 +43,7 @@ export class StockController { private readonly income: StockIncomeService, private readonly companyInfo: GetStockCompanyUseCase, ) {} + @Get() @ApiOkResponse({ type: StockSearchResourceDto }) search( @@ -53,6 +56,7 @@ export class StockController { query.sync === 'observe', ); } + @Get('by-ticker/:ticker') @ApiOkResponse({ type: StockResourceDto }) getByTicker( @@ -66,6 +70,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid') @ApiOkResponse({ type: StockResourceDto }) get( @@ -75,6 +80,7 @@ export class StockController { ) { return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/quote') @ApiOkResponse({ type: StockQuoteResourceDto }) quote( @@ -84,6 +90,7 @@ export class StockController { ) { return this.market.quote(request.user.sub, uid, query.sync === 'observe'); } + @Get(':uid/candles') @ApiOkResponse({ type: StockCandlesResourceDto }) candles( @@ -98,6 +105,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/position') @ApiOkResponse({ type: StockPositionResourceDto }) position( @@ -111,6 +119,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/company') @ApiOkResponse({ type: StockCompanyResourceDto }) company( @@ -124,6 +133,7 @@ export class StockController { query.sync === 'observe', ); } + @Get(':uid/dividends') @ApiOkResponse({ type: StockDividendsResourceDto }) dividends( diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index c722395..4fa0620 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform } from 'class-transformer'; import { @@ -12,7 +8,12 @@ import { MaxLength, MinLength, } from 'class-validator'; + import { MoneyDto } from '../../../../../common/money.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../../investment-resource.dto'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; const MAX_INSTRUMENT_QUERY_LENGTH = 80; @@ -36,7 +37,7 @@ export class StockTickerParamsDto { @IsString() @MinLength(1) @MaxLength(MAX_INSTRUMENT_QUERY_LENGTH) - @Matches(/^[A-Za-z0-9][A-Za-z0-9._@+-]*$/) + @Matches(/^[A-Z0-9][\w.@+-]*$/i) declare ticker: string; } export class StockCandlesQueryDto extends InvestmentReadQueryDto { @@ -46,7 +47,7 @@ export class StockCandlesQueryDto extends InvestmentReadQueryDto { }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - period?: 'month' | 'halfYear' | 'year' | 'all'; + period?: 'all' | 'halfYear' | 'month' | 'year'; } export class StockDto { @ApiProperty({ format: 'uuid' }) declare instrumentUid: string; @@ -54,48 +55,63 @@ export class StockDto { description: 'Unambiguous ticker URL segment, or the exact instrument UID.', }) declare slug: string; + @ApiProperty() declare name: string; - @ApiProperty({ type: String, nullable: true }) declare isin: string | null; + @ApiProperty({ type: String, nullable: true }) declare isin: null | string; @ApiProperty({ type: String, nullable: true }) declare classCode: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare realExchange: - string | null; + null | string; + @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: boolean | null; - @ApiProperty({ type: String, nullable: true }) declare ticker: string | null; + + @ApiProperty({ type: String, nullable: true }) declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) declare logoName: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true }) declare shareType: - string | null; - @ApiProperty({ type: String, nullable: true }) declare sector: string | null; - @ApiProperty({ type: Number, nullable: true }) declare lot: number | null; + null | string; + + @ApiProperty({ type: String, nullable: true }) declare sector: null | string; + @ApiProperty({ type: Number, nullable: true }) declare lot: null | number; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; } export class StockSearchResultDto extends StockDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; } export class StockQuoteDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare priceAt: string | null; + declare priceAt: null | string; + @ApiProperty({ type: String, nullable: true }) declare tradingStatus: - string | null; + null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare change: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare changePercent: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare previousCloseDate: string | null; + declare previousCloseDate: null | string; + @ApiProperty({ type: MoneyDto, nullable: true }) declare previousClose: MoneyDto | null; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockCandleDto { @@ -104,29 +120,36 @@ export class StockCandleDto { @ApiProperty() declare isComplete: boolean; } export class StockCandlesDto { - @ApiProperty({ type: [StockCandleDto] }) declare candles: StockCandleDto[]; + @ApiProperty({ type: StockCandleDto, isArray: true }) declare candles: StockCandleDto[]; @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare historyFrom: string | null; + declare historyFrom: null | string; + @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockPositionRowDto extends PortfolioPositionDto { @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; + null | string; } export class StockPositionDto { @ApiProperty() declare quantity: string; @ApiProperty({ type: MoneyDto, nullable: true }) declare currentValue: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare averagePurchasePrice: MoneyDto | null; + @ApiProperty({ type: MoneyDto, nullable: true }) declare absoluteReturn: MoneyDto | null; + @ApiProperty({ type: String, nullable: true }) declare returnPercent: - string | null; - @ApiProperty({ type: [StockPositionRowDto] }) + null | string; + + @ApiProperty({ type: StockPositionRowDto, isArray: true }) declare accounts: StockPositionRowDto[]; + @ApiProperty() declare isStale: boolean; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } @@ -134,90 +157,108 @@ export class StockMetricDto { @ApiProperty() declare key: string; @ApiProperty() declare value: number; @ApiProperty({ enum: ['money', 'ratio', 'percent'] }) declare unit: - 'money' | 'ratio' | 'percent'; + 'money' | 'percent' | 'ratio'; + @ApiProperty({ type: String, nullable: true }) declare currency: - string | null; + null | string; + @ApiProperty({ type: String, nullable: true, enum: ['TTM', 'MRQ'] }) - declare period: 'TTM' | 'MRQ' | null; + declare period: 'MRQ' | 'TTM' | null; } export class StockCompanyDto { @ApiProperty({ type: String, nullable: true }) declare description: - string | null; - @ApiProperty({ type: [StockMetricDto] }) declare metrics: StockMetricDto[]; - @ApiProperty({ type: [String] }) declare unavailable: string[]; + null | string; + + @ApiProperty({ type: StockMetricDto, isArray: true }) declare metrics: StockMetricDto[]; + @ApiProperty({ type: String, isArray: true }) declare unavailable: string[]; @ApiProperty({ format: 'date-time' }) declare observedAt: string; } export class StockDividendDto { @ApiProperty({ type: MoneyDto, nullable: true }) declare perShare: MoneyDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare recordDate: string | null; + declare recordDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare lastBuyDate: string | null; + declare lastBuyDate: null | string; + @ApiProperty({ type: String, nullable: true, format: 'date' }) - declare paymentDate: string | null; + declare paymentDate: null | string; + @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: - 'upcoming' | 'past' | 'undated'; + 'past' | 'undated' | 'upcoming'; + @ApiProperty({ type: MoneyDto, nullable: true }) declare estimatedAmount: MoneyDto | null; } export class StockDividendsDto { - @ApiProperty({ type: [StockDividendDto] }) + @ApiProperty({ type: StockDividendDto, isArray: true }) declare dividends: StockDividendDto[]; + @ApiProperty({ format: 'date' }) declare from: string; @ApiProperty({ format: 'date' }) declare to: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare positionAt: string | null; + declare positionAt: null | string; + @ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare positionIsStale: boolean; } export class StockPositionResourceDto { @ApiProperty({ type: StockPositionDto, nullable: true }) - declare data: StockPositionDto | null; + declare data: null | StockPositionDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockSearchResourceDto { + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema. @ApiProperty({ type: StockSearchResultDto, isArray: true, nullable: true }) - declare data: StockSearchResultDto[] | null; + declare data: null | StockSearchResultDto[]; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockResourceDto { @ApiProperty({ type: StockDto, nullable: true }) - declare data: StockDto | null; + declare data: null | StockDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockQuoteResourceDto { @ApiProperty({ type: StockQuoteDto, nullable: true }) - declare data: StockQuoteDto | null; + declare data: null | StockQuoteDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCompanyResourceDto { @ApiProperty({ type: StockCompanyDto, nullable: true }) - declare data: StockCompanyDto | null; + declare data: null | StockCompanyDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockCandlesResourceDto { @ApiProperty({ type: StockCandlesDto, nullable: true }) - declare data: StockCandlesDto | null; + declare data: null | StockCandlesDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } export class StockDividendsResourceDto { @ApiProperty({ type: StockDividendsDto, nullable: true }) - declare data: StockDividendsDto | null; + declare data: null | StockDividendsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/investment-calendar.ts b/src/modules/investments/investment-calendar.ts index 4b7eb98..c2d76e0 100644 --- a/src/modules/investments/investment-calendar.ts +++ b/src/modules/investments/investment-calendar.ts @@ -39,8 +39,9 @@ export function dateParts(value: string): [number, number, number] { number, number, ]; - if (utcDate(value).toISOString().slice(0, 10) !== value) + if (utcDate(value).toISOString().slice(0, 10) !== value) { throw new Error('Invalid date'); + } return parts; } diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index bf0496a..32ab6e8 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -14,18 +14,22 @@ export class InvestmentReadQueryDto { export class InvestmentSyncDto { @ApiProperty({ type: String, nullable: true }) - declare id: string | null; + declare id: null | string; + @ApiProperty({ enum: syncStateValues }) declare state: (typeof syncStateValues)[number]; + @ApiProperty({ type: String, nullable: true }) - declare errorCode: string | null; + declare errorCode: null | string; } export class InvestmentRangeDto { @ApiPropertyOptional() accountId?: string; + @ApiProperty({ format: 'date-time' }) declare from: string; + @ApiProperty({ format: 'date-time' }) declare to: string; } @@ -33,38 +37,51 @@ export class InvestmentRangeDto { export class InvestmentCoverageDto { @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare requested: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare completeRanges: InvestmentRangeDto[]; + @ApiProperty({ type: InvestmentRangeDto, isArray: true }) declare missingRanges: InvestmentRangeDto[]; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare asOf: string | null; + declare asOf: null | string; + @ApiProperty({ enum: ['known', 'unknown'] }) declare historyBoundary: 'known' | 'unknown'; + @ApiProperty({ enum: ['missing', 'loading', 'partial', 'complete', 'failed'], }) declare historyState: - 'missing' | 'loading' | 'partial' | 'complete' | 'failed'; + 'complete' | 'failed' | 'loading' | 'missing' | 'partial'; } export class InvestmentDependencyDto { @ApiProperty() declare dataRevision: string; + @ApiProperty() declare stateVersion: string; + @ApiPropertyOptional({ type: String }) unavailableReason?: string; + @ApiPropertyOptional({ type: () => InvestmentCoverageDto }) coverage?: InvestmentCoverageDto; + @ApiProperty() declare resourceKey: string; + @ApiProperty({ enum: availabilityValues }) declare availability: (typeof availabilityValues)[number]; + @ApiProperty({ enum: freshnessValues }) declare freshness: (typeof freshnessValues)[number]; + @ApiProperty({ type: String, format: 'date-time', nullable: true }) - declare lastSuccessfulAt: string | null; + declare lastSuccessfulAt: null | string; + @ApiProperty({ type: InvestmentSyncDto }) declare sync: InvestmentSyncDto; } diff --git a/src/modules/investments/investment-resource.mapper.ts b/src/modules/investments/investment-resource.mapper.ts index 0e47aea..dc7da49 100644 --- a/src/modules/investments/investment-resource.mapper.ts +++ b/src/modules/investments/investment-resource.mapper.ts @@ -1,25 +1,26 @@ import { createHash } from 'node:crypto'; + import { InvestmentResourceMetaDto } from './investment-resource.dto'; export function investmentResourceMeta(input: { - resourceKey: string; - dataRevision: string; availability: InvestmentResourceMetaDto['availability']; + coverage?: NonNullable; + dataRevision: string; + dependencies?: InvestmentResourceMetaDto[]; fetchedAt: Date | null; freshForMs: number; - work: { id: string; state: string; errorCode: string | null } | null; - dependencies?: InvestmentResourceMetaDto[]; - coverage?: InvestmentResourceMetaDto['coverage']; + resourceKey: string; unavailableReason?: string; + work: null | { errorCode: null | string; id: string; state: string }; }): InvestmentResourceMetaDto { const ownSync = { id: input.work?.id ?? null, state: - input.work?.state === 'running' - ? ('running' as const) - : input.work?.state === 'failed' - ? ('failed' as const) - : ('idle' as const), + input.work?.state === 'running' ? + ('running' as const) : + input.work?.state === 'failed' ? + ('failed' as const) : + ('idle' as const), errorCode: input.work?.errorCode ?? null, }; const states = [ @@ -33,11 +34,11 @@ export function investmentResourceMeta(input: { states.find((state) => state.state === 'queued') ?? states.find((state) => state.state === 'failed') ?? ownSync; - const sourceFreshness = input.fetchedAt - ? Date.now() - input.fetchedAt.valueOf() < input.freshForMs - ? ('fresh' as const) - : ('stale' as const) - : ('unknown' as const); + const sourceFreshness = input.fetchedAt ? + Date.now() - input.fetchedAt.valueOf() < input.freshForMs ? + ('fresh' as const) : + ('stale' as const) : + ('unknown' as const); const freshness = [ sourceFreshness, ...(input.dependencies ?? []) @@ -51,19 +52,20 @@ export function investmentResourceMeta(input: { ), ] .filter((timestamp): timestamp is string => Boolean(timestamp)) + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. .sort(); const meta = { resourceKey: input.resourceKey, - ...(input.unavailableReason - ? { unavailableReason: input.unavailableReason } - : {}), + ...(input.unavailableReason ? + { unavailableReason: input.unavailableReason } : + {}), dataRevision: input.dataRevision, availability: input.availability, - freshness: freshness.includes('unknown') - ? ('unknown' as const) - : freshness.includes('stale') - ? ('stale' as const) - : ('fresh' as const), + freshness: freshness.includes('unknown') ? + ('unknown' as const) : + freshness.includes('stale') ? + ('stale' as const) : + ('fresh' as const), lastSuccessfulAt: timestamps[0] ?? null, sync, ...(input.coverage ? { coverage: input.coverage } : {}), @@ -77,9 +79,9 @@ export function investmentResourceMeta(input: { ...meta, // The requested window is identified by resourceKey. Its moving "now" // boundary is not a change to saved coverage between status and GET. - coverage: meta.coverage - ? { ...meta.coverage, requested: undefined } - : undefined, + coverage: meta.coverage ? + { ...meta.coverage, requested: undefined } : + undefined, }), ) .digest('base64url'), diff --git a/src/modules/investments/investment-sync-plan.spec.ts b/src/modules/investments/investment-sync-plan.spec.ts index 7dd5b03..58d0915 100644 --- a/src/modules/investments/investment-sync-plan.spec.ts +++ b/src/modules/investments/investment-sync-plan.spec.ts @@ -1,7 +1,7 @@ import { - planInvestmentScreen, - ledgerRange, accountHistory, + ledgerRange, + planInvestmentScreen, } from './investment-sync-plan'; describe('screen dependency and history planning', () => { diff --git a/src/modules/investments/investment-sync-plan.ts b/src/modules/investments/investment-sync-plan.ts index 42a638b..e5b01b7 100644 --- a/src/modules/investments/investment-sync-plan.ts +++ b/src/modules/investments/investment-sync-plan.ts @@ -1,58 +1,58 @@ import { planIncomePeriod } from './income/income.api'; import { dateOnlyBounds, moscowDate } from './investment-calendar'; import { - operationIntervals, type OperationInterval, + operationIntervals, } from './operations/domain/operations-sync-plan'; export type InvestmentScreen = { - screen: - | 'accounts' - | 'account' - | 'operations' - | 'income' - | 'events' - | 'stocks' - | 'bonds' - | 'stock' - | 'bond'; accountId?: string; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + candleRange?: { from: Date; to: Date }; from?: string; - to?: string; instrumentUid?: string; query?: string; - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; - candleRange?: { from: Date; to: Date }; + screen: + | 'account' | + 'accounts' | + 'bond' | + 'bonds' | + 'events' | + 'income' | + 'operations' | + 'stock' | + 'stocks'; + to?: string; }; const defaultEventDays = 30; const incomeHistoryYears = 5; const candleMonths = { month: 1, halfYear: 6, year: 12 }; -type Account = { accountId: string; openedOn: string | null }; +type Account = { accountId: string; openedOn: null | string }; type Instrument = { - uid: string; - type: 'STOCK' | 'BOND'; historyFrom: Date | null; + type: 'BOND' | 'STOCK'; + uid: string; }; type Source = { - kind: - | 'membership' - | 'portfolio' - | 'operations' - | 'metadata' - | 'catalog' - | 'quote' - | 'company' - | 'metrics' - | 'calendar' - | 'candles'; accountId?: string; - instrumentUid?: string; - instrumentType?: 'STOCK' | 'BOND'; from?: Date; - to?: Date; + instrumentType?: 'BOND' | 'STOCK'; + instrumentUid?: string; + kind: + | 'calendar' | + 'candles' | + 'catalog' | + 'company' | + 'membership' | + 'metadata' | + 'metrics' | + 'operations' | + 'portfolio' | + 'quote'; + priority: 'discovery' | 'history' | 'screen'; query?: string; - priority: 'discovery' | 'screen' | 'history'; + to?: Date; }; // Expands screen scope, never credentials or arbitrary caller-supplied jobs. @@ -74,19 +74,19 @@ export function planInvestmentScreen( screen.accountId === 'all' || account.accountId === screen.accountId, ); - const unavailable: { resourceKey: string; reason: string }[] = []; + const unavailable: { reason: string; resourceKey: string }[] = []; const operations = (from: Date, to: Date, instrumentUid?: string) => { const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, ...(instrumentUid ? { instrumentUid } : {}), }, - ] - : [], + ] : + [], ); sources.push( ...operationIntervals(targets).map((range) => ({ @@ -105,16 +105,18 @@ export function planInvestmentScreen( })), ); const knownOpening = - accounts.length && accounts.every((account) => account.openedOn) - ? new Date( + accounts.length && accounts.every((account) => account.openedOn) ? + new Date( + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. `${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`, - ) - : undefined; + ) : + undefined; if (!context.membershipKnown) return { sources, unavailable, now }; if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes(screen.screen) - ) + ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) + ) { portfolio(); + } if ( screen.screen === 'operations' || screen.screen === 'stock' || @@ -137,18 +139,19 @@ export function planInvestmentScreen( ); for (const range of [ period.selected, - ...(screen.screen === 'income' - ? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] - : []), + ...(screen.screen === 'income' ? + [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] : + []), ]) { const bounds = dateOnlyBounds(range, now); operations(bounds.from, bounds.to); } - if (screen.screen === 'income' && !period.yearAgo) + if (screen.screen === 'income' && !period.yearAgo) { unavailable.push({ resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier reason: 'calendar_or_account_boundary', }); + } if (screen.screen === 'account') { const range = ledgerRange( screen.from, @@ -160,13 +163,14 @@ export function planInvestmentScreen( operations(range.from, range.to); } } - if (screen.screen === 'stocks' || screen.screen === 'bonds') + if (screen.screen === 'stocks' || screen.screen === 'bonds') { sources.push({ kind: 'catalog', instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND', query: screen.query?.trim().toUpperCase() ?? '', priority: 'screen', }); + } const instruments = context.instruments.filter( (instrument) => !screen.instrumentUid || instrument.uid === screen.instrumentUid, @@ -175,20 +179,22 @@ export function planInvestmentScreen( screen.instrumentUid && !instruments.length && (screen.screen === 'stock' || screen.screen === 'bond') - ) + ) { instruments.push({ uid: screen.instrumentUid, type: screen.screen === 'stock' ? 'STOCK' : 'BOND', historyFrom: null, }); + } for (const instrument of instruments) { const identity = { instrumentUid: instrument.uid, instrumentType: instrument.type, }; sources.push({ ...identity, kind: 'metadata', priority: 'screen' }); - if (['stocks', 'bonds', 'stock', 'bond'].includes(screen.screen)) + if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) { sources.push({ ...identity, kind: 'quote', priority: 'screen' }); + } if (screen.screen === 'stock' || screen.screen === 'bond') { sources.push({ ...identity, @@ -197,47 +203,51 @@ export function planInvestmentScreen( }); const from = new Date(now); const period = screen.candlePeriod ?? 'month'; - if (period !== 'all') + if (period !== 'all') { + // eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union. from.setUTCMonth(from.getUTCMonth() - candleMonths[period]); + } const range = screen.candleRange ?? - (period === 'all' - ? instrument.historyFrom - ? { from: instrument.historyFrom, to: now } - : null - : { from, to: now }); - if (range) + (period === 'all' ? + instrument.historyFrom ? + { from: instrument.historyFrom, to: now } : + null : + { from, to: now }); + if (range) { sources.push({ ...identity, kind: 'candles', ...range, priority: 'screen', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } - if (['account', 'events', 'stock', 'bond'].includes(screen.screen)) { + if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) { const day = - screen.screen === 'stock' || screen.screen === 'bond' - ? moscowDate(now) - : now.toISOString().slice(0, 10); + screen.screen === 'stock' || screen.screen === 'bond' ? + moscowDate(now) : + now.toISOString().slice(0, 10); const from = - screen.screen === 'events' && screen.from - ? new Date(`${screen.from}T00:00:00Z`) - : new Date(`${day}T00:00:00Z`); + screen.screen === 'events' && screen.from ? + new Date(`${screen.from}T00:00:00Z`) : + new Date(`${day}T00:00:00Z`); const to = - screen.screen === 'events' && screen.to - ? new Date(`${screen.to}T00:00:00Z`) - : new Date(from); + screen.screen === 'events' && screen.to ? + new Date(`${screen.to}T00:00:00Z`) : + new Date(from); if ( !(screen.screen === 'events' && screen.to) && screen.screen !== 'stock' && screen.screen !== 'bond' - ) + ) { to.setUTCDate(to.getUTCDate() + defaultEventDays); + } if (screen.screen === 'stock' || screen.screen === 'bond') { from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears); to.setUTCFullYear(to.getUTCFullYear() + 1); @@ -252,9 +262,9 @@ export function planInvestmentScreen( } if ( !force && - ['account', 'operations', 'income', 'events'].includes(screen.screen) + ['account', 'events', 'income', 'operations'].includes(screen.screen) ) { - if (instrument.historyFrom) + if (instrument.historyFrom) { sources.push({ ...identity, kind: 'candles', @@ -262,14 +272,15 @@ export function planInvestmentScreen( to: now, priority: 'history', }); - else + } else { unavailable.push({ resourceKey: `candles/${instrument.uid}/all`, reason: 'history_start_unavailable', }); + } } } - if (!force && !['stocks', 'bonds'].includes(screen.screen)) + if (!force && !['bonds', 'stocks'].includes(screen.screen)) { sources.push( ...accountHistory(accounts, now).map((range) => ({ ...range, @@ -277,6 +288,7 @@ export function planInvestmentScreen( priority: 'history' as const, })), ); + } const priorities = { discovery: 0, screen: 1, history: 2 }; sources.sort( (left, right) => priorities[left.priority] - priorities[right.priority], @@ -290,9 +302,9 @@ export function accountHistory( ): OperationInterval[] { return accounts.flatMap((account) => { const from = new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ); return from < now ? [{ accountId: account.accountId, from, to: now }] : []; }); @@ -305,21 +317,22 @@ export function ledgerRange( accounts: Account[], now: Date, ) { - const opening = accounts.every((account) => account.openedOn) - ? accounts.map((account) => account.openedOn ?? '').sort()[0] - : undefined; - const from = fromValue - ? new Date(fromValue) - : instrumentUid - ? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') - : new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); - const end = toValue - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(toValue) - ? `${toValue}T23:59:59.999Z` - : toValue, - ) - : now; + const opening = accounts.every((account) => account.openedOn) ? + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. + accounts.map((account) => account.openedOn ?? '').sort()[0] : + undefined; + const from = fromValue ? + new Date(fromValue) : + instrumentUid ? + new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') : + new Date(Date.UTC(now.getUTCFullYear(), 0, 1)); + const end = toValue ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(toValue) ? + `${toValue}T23:59:59.999Z` : + toValue, + ) : + now; return { from, to: new Date(Math.min(end.valueOf() + (toValue ? 1 : 0), now.valueOf())), diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index cb37443..8e635eb 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -1,22 +1,23 @@ -import { accountHistory } from '../../investment-sync-plan'; import { Injectable, NotFoundException } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; + import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { TbankAccessService, TbankClient, } from '../../../../integrations/tbank/tbank.api'; +import { accountHistory } from '../../investment-sync-plan'; +import { + type OperationInterval, + operationIntervals, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { BrokerageOperationsRepository, type OperationAttempt, } from '../infrastructure/brokerage-operations.repository'; -import { - operationIntervals, - planOperationRead, - type OperationInterval, - uncoveredOperationIntervals, -} from '../domain/operations-sync-plan'; const operationsPageSize = 1000; const priorities = { screen: 1, history: 2 } as const; @@ -29,6 +30,7 @@ export class BrokerageOperationsSyncService { string, { attempt: OperationAttempt; priority: number } >(); + private draining = false; // Only short DB reservations/publications are serialized, never RPCs. private reservations: Promise = Promise.resolve(); @@ -44,10 +46,10 @@ export class BrokerageOperationsSyncService { userId: string, intervals: OperationInterval[], options: { - movingEnd?: boolean; force?: boolean; - observe?: boolean; history?: boolean; + movingEnd?: boolean; + observe?: boolean; revision?: string; } = {}, ) { @@ -55,8 +57,9 @@ export class BrokerageOperationsSyncService { if ( !connection.membershipAt || (options.revision && options.revision !== connection.revision) - ) + ) { return { plan: null, handle: null }; + } if ( intervals.some( (interval) => @@ -64,8 +67,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } const coverage = await this.repository.coverage( userId, connection.revision, @@ -76,24 +80,24 @@ export class BrokerageOperationsSyncService { ...intervals.map((interval) => interval.to.valueOf()), ); const targets = intervals.map((interval) => - options.movingEnd && interval.to.valueOf() === movingTo - ? { ...interval, to: now } - : interval, + options.movingEnd && interval.to.valueOf() === movingTo ? + { ...interval, to: now } : + interval, ); const plan = planOperationRead(targets, coverage, now, options); const complete = plan.intervals.every( (interval) => interval.availability === 'complete', ); const handle = - !options.observe && plan.load.length - ? await this.request( + !options.observe && plan.load.length ? + await this.request( userId, plan.load, complete || Boolean(options.force), 'screen', connection.revision, - ) - : null; + ) : + null; if (!options.observe && !options.force && options.history) { const accountIds = new Set( intervals.map((interval) => interval.accountId), @@ -108,7 +112,7 @@ export class BrokerageOperationsSyncService { movingEnd: true, }).load; const remaining = uncoveredOperationIntervals(missing, targets); - if (remaining.length) + if (remaining.length) { await this.request( userId, remaining, @@ -116,6 +120,7 @@ export class BrokerageOperationsSyncService { 'history', connection.revision, ); + } } return { plan, handle }; } @@ -126,7 +131,7 @@ export class BrokerageOperationsSyncService { atomic: boolean, priority: keyof typeof priorities = 'screen', revision?: string, - ): Promise { + ): Promise { const connection = await this.access.local(userId); if ( connection.membershipAt && @@ -136,8 +141,9 @@ export class BrokerageOperationsSyncService { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (revision && revision !== connection.revision) return null; const cutoff = new Date(); const bounded = operationIntervals(intervals) @@ -155,6 +161,7 @@ export class BrokerageOperationsSyncService { this.processRunId, atomic, this.runningAttempts, + // eslint-disable-next-line security/detect-object-injection -- The priority belongs to the internal priority union. priorities[priority], ); if (!requested) return null; @@ -258,17 +265,18 @@ export class BrokerageOperationsSyncService { Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + 1, ), }, - ...(interval.instrumentUid - ? { instrumentId: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentId: interval.instrumentUid } : + {}), cursor, limit: operationsPageSize, state: 'OPERATION_STATE_EXECUTED', }); if ( !(await this.repository.stagePage(attempt, interval, cursor, response)) - ) + ) { return false; + } if (!plan.atomic && !response.hasNext) { if ( !(await this.repository.publish( @@ -277,8 +285,9 @@ export class BrokerageOperationsSyncService { new Date(), true, )) - ) + ) { return false; + } } // One page per turn: newly requested screen work can overtake backfill. // TbankClient alone owns the bounded RPC queue and retries. diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index b29231e..d9bac28 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -1,41 +1,42 @@ -import { - ledgerRange, - accountHistory, - planInvestmentScreen, -} from '../../investment-sync-plan'; -import { ConfigService } from '@nestjs/config'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; -import { - planOperationRead, - uncoveredOperationIntervals, - operationIntervalsOverlap, -} from '../domain/operations-sync-plan'; import { BadRequestException, Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { createHash, createHmac, timingSafeEqual } from 'node:crypto'; + import { InstrumentMetadataService, metadataKey, TbankAccessService, TbankMembershipService, - tbankSourceTtlMs, type TbankOperationItem, + tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { DateOnlyRange, dateOnlyBounds } from '../../investment-calendar'; +import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { - DEFAULT_OPERATIONS_PAGE_SIZE, - BrokerageOperationsQueryDto, -} from '../presentation/brokerage-operation.dto'; + accountHistory, + ledgerRange, + planInvestmentScreen, +} from '../../investment-sync-plan'; +import { + operationIntervalsOverlap, + planOperationRead, + uncoveredOperationIntervals, +} from '../domain/operations-sync-plan'; import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper'; +import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository'; +import { + BrokerageOperationsQueryDto, + DEFAULT_OPERATIONS_PAGE_SIZE, +} from '../presentation/brokerage-operation.dto'; +import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service'; type LocalOperationsCursor = { - movingEnd?: boolean; - binding: string; - to: string; - at: string; accountId: string; + at: string; + binding: string; id: string; + movingEnd?: boolean; + to: string; }; @Injectable() @@ -70,18 +71,19 @@ export class BrokerageOperationsService { const from = range.from; const to = cursor ? new Date(cursor.to) : range.to; const movingEnd = cursor?.movingEnd ?? range.to.valueOf() === now.valueOf(); + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. const categories = [...new Set(query.categories ?? [])].sort(); const limit = query.limit ?? DEFAULT_OPERATIONS_PAGE_SIZE; const filterKey = JSON.stringify([ accountId, from.toISOString(), - query.to - ? new Date( - /^\d{4}-\d{2}-\d{2}$/.test(query.to) - ? `${query.to}T23:59:59.999Z` - : query.to, - ).toISOString() - : 'now', + query.to ? + new Date( + /^\d{4}-\d{2}-\d{2}$/.test(query.to) ? + `${query.to}T23:59:59.999Z` : + query.to, + ).toISOString() : + 'now', query.instrumentUid ?? null, categories, limit, @@ -118,9 +120,9 @@ export class BrokerageOperationsService { source.priority === 'screen' && source.accountId && source.from && - source.to - ? [{ accountId: source.accountId, from: source.from, to: source.to }] - : [], + source.to ? + [{ accountId: source.accountId, from: source.from, to: source.to }] : + [], ); if (query.sync !== 'observe') { const discovery = await this.membership.request( @@ -148,9 +150,9 @@ export class BrokerageOperationsService { accountId === 'all' || account.accountId === accountId, ) .flatMap((account) => - currentRange.from < currentRange.to - ? [{ accountId: account.accountId, ...currentRange }] - : [], + currentRange.from < currentRange.to ? + [{ accountId: account.accountId, ...currentRange }] : + [], ); await this.sync.readPlan(userId, targets, { movingEnd, @@ -170,16 +172,16 @@ export class BrokerageOperationsService { const saved = await this.repository.readPage(readInput); const accounts = saved?.connection.accounts ?? []; const targets = accounts.flatMap((account) => - from < to - ? [ + from < to ? + [ { accountId: account.accountId, from, to, instrumentUid: query.instrumentUid, }, - ] - : [], + ] : + [], ); const plan = planOperationRead(targets, saved?.coverage ?? [], now, { movingEnd, @@ -191,8 +193,8 @@ export class BrokerageOperationsService { row.accountId === target.accountId && (!row.instrumentUid || row.instrumentUid === target.instrumentUid) && - row.from < target.to && - target.from < row.to, + row.from < target.to && + target.from < row.to, ) .map((row) => ({ accountId: target.accountId, @@ -230,13 +232,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { accountId: string; from: Date; to: Date }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const historyTargets = accountHistory(accounts, now); const history = planOperationRead( historyTargets, @@ -273,9 +270,11 @@ export class BrokerageOperationsService { ] as const; }), ); - for (const identity of identities.values()) - if (!instruments.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!instruments.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', @@ -288,17 +287,17 @@ export class BrokerageOperationsService { const meta = investmentResourceMeta({ resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`, dataRevision: saved?.binding ?? 'missing', - availability: !available - ? 'missing' - : complete - ? 'complete' - : confirmed.length - ? 'partial' - : 'missing', + availability: !available ? + 'missing' : + complete ? + 'complete' : + confirmed.length ? + 'partial' : + 'missing', fetchedAt: lastSuccessfulAt, - freshForMs: plan.intervals.some((interval) => interval.stale) - ? 0 - : Number.POSITIVE_INFINITY, + freshForMs: plan.intervals.some((interval) => interval.stale) ? + 0 : + Number.POSITIVE_INFINITY, work: work ?? saved?.membershipWork ?? null, dependencies: [membershipMeta, ...metadataDependencies], coverage: { @@ -312,8 +311,8 @@ export class BrokerageOperationsService { (interval) => interval.accountId === account.accountId && interval.asOf, ), - ) - ? new Date( + ) ? + new Date( Math.min( ...accounts.map((account) => Math.max( @@ -326,42 +325,42 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: knownBoundary ? 'known' : 'unknown', historyState: saved?.work.some( (work) => work.state === 'running' && work.priority > 1, - ) - ? 'loading' - : saved?.work.some( - (work) => work.state === 'failed' && work.priority > 1, - ) - ? 'failed' - : knownBoundary && - history.intervals.every( - (interval) => interval.availability === 'complete', - ) - ? 'complete' - : saved?.coverage.length - ? 'partial' - : 'missing', + ) ? + 'loading' : + saved?.work.some( + (work) => work.state === 'failed' && work.priority > 1, + ) ? + 'failed' : + knownBoundary && + history.intervals.every( + (interval) => interval.availability === 'complete', + ) ? + 'complete' : + saved?.coverage.length ? + 'partial' : + 'missing', }, }); const last = rows.at(-1); const hasNext = Boolean(saved && saved.rows.length > limit); return { data: - meta.availability === 'missing' - ? null - : { + meta.availability === 'missing' ? + null : + { accountId, items: rows.map((row) => ({ ...mapBrokerageOperation( row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -369,8 +368,8 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), + } : + {}), logoName: instruments .get(metadataKey(row.payload as TbankOperationItem)) @@ -378,16 +377,16 @@ export class BrokerageOperationsService { })), hasNext, nextCursor: - hasNext && last && saved - ? this.encodeCursor({ + hasNext && last && saved ? + this.encodeCursor({ binding: saved.binding, movingEnd, to: to.toISOString(), at: last.occurredAt.toISOString(), accountId: last.accountId, id: last.brokerOperationId, - }) - : null, + }) : + null, observedAt: now.toISOString(), }, meta, @@ -413,23 +412,25 @@ export class BrokerageOperationsService { extra || actual.length !== expected.length || !timingSafeEqual(actual, expected) - ) + ) { throw new Error(); + } const cursor = JSON.parse( Buffer.from(body, 'base64url').toString(), ) as Partial; if ( (cursor.movingEnd !== undefined && typeof cursor.movingEnd !== 'boolean') || - !cursor.binding || - !cursor.accountId || - !cursor.id || - !cursor.at || - !cursor.to || - !Number.isFinite(Date.parse(cursor.at)) || - !Number.isFinite(Date.parse(cursor.to)) - ) + !cursor.binding || + !cursor.accountId || + !cursor.id || + !cursor.at || + !cursor.to || + !Number.isFinite(Date.parse(cursor.at)) || + !Number.isFinite(Date.parse(cursor.to)) + ) { throw new Error(); + } return cursor as LocalOperationsCursor; } catch { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); @@ -465,12 +466,13 @@ export class BrokerageOperationsService { ...period, })), ); - if (targets.length) + if (targets.length) { await this.sync.readPlan(userId, targets, { movingEnd: snapshot.periods?.some((period) => period.movingEnd), history: true, revision: connection.revision, }); + } }; if (!observe) { const discovery = await this.membership.request( @@ -530,17 +532,8 @@ export class BrokerageOperationsService { sources .map((row) => row.fetchedAt) .sort((a, b) => a.valueOf() - b.valueOf())[0] ?? - (complete ? saved?.connection.membershipAt : null) ?? - null; - const serialize = (range: { - accountId: string; - from: Date; - to: Date; - }) => ({ - accountId: range.accountId, - from: range.from.toISOString(), - to: range.to.toISOString(), - }); + (complete ? saved?.connection.membershipAt : null) ?? + null; const confirmed = plan.intervals.flatMap((target) => sources .filter( @@ -555,15 +548,16 @@ export class BrokerageOperationsService { })), ); const meta = investmentResourceMeta({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, dataRevision: saved?.binding ?? 'missing', - availability: !known - ? 'missing' - : complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + availability: !known ? + 'missing' : + complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', fetchedAt, freshForMs: plan.intervals.some((range) => range.stale) ? 0 : Infinity, work, @@ -576,8 +570,8 @@ export class BrokerageOperationsService { confirmed, ).map(serialize), asOf: - complete && targets.length - ? new Date( + complete && targets.length ? + new Date( Math.min( ...targets.map((target) => Math.max( @@ -592,24 +586,24 @@ export class BrokerageOperationsService { ), ), ), - ).toISOString() - : null, + ).toISOString() : + null, historyBoundary: (saved?.connection.accounts ?? []).every( (account) => account.openedOn, - ) - ? 'known' - : 'unknown', - historyState: complete - ? 'complete' - : sources.length - ? 'partial' - : 'missing', + ) ? + 'known' : + 'unknown', + historyState: complete ? + 'complete' : + sources.length ? + 'partial' : + 'missing', }, }); return { meta, - items: complete - ? (saved?.rows ?? []) + items: complete ? + (saved?.rows ?? []) .filter((row) => plan.intervals.some( (interval) => @@ -623,8 +617,8 @@ export class BrokerageOperationsService { row.payload as TbankOperationItem, row.occurredAt, ), - ...(accountId === 'all' - ? { + ...(accountId === 'all' ? + { account: { id: row.accountId, name: @@ -632,11 +626,15 @@ export class BrokerageOperationsService { (account) => account.accountId === row.accountId, )?.name ?? row.accountId, }, - } - : {}), - })) - : null, + } : + {}), + })) : + null, }; }); } } + +function serialize(range: { accountId: string; from: Date; to: Date }) { + return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() }; +} diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 555a43c..b0e73ca 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -1,8 +1,8 @@ export type OperationInterval = { accountId: string; from: Date; - to: Date; instrumentUid?: string; + to: Date; }; // Normalize duplicate/overlapping targets before creating a durable identity. @@ -12,8 +12,10 @@ export function operationIntervals( ): OperationInterval[] { const groups = new Map(); for (const interval of requested) { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } const key = JSON.stringify([ interval.accountId, interval.instrumentUid ?? '', @@ -23,17 +25,18 @@ export function operationIntervals( groups.set(key, group); } const result: OperationInterval[] = []; + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. for (const key of [...groups.keys()].sort()) { const merged: OperationInterval[] = []; for (const interval of (groups.get(key) ?? []).sort( (left, right) => left.from.valueOf() - right.from.valueOf(), )) { const previous = merged.at(-1); - if (previous && interval.from <= previous.to) + if (previous && interval.from <= previous.to) { previous.to = new Date( Math.max(previous.to.valueOf(), interval.to.valueOf()), ); - else merged.push({ ...interval }); + } else merged.push({ ...interval }); } for (const interval of merged) { let from = interval.from; @@ -61,8 +64,8 @@ export function operationIntervalsOverlap( (!left.instrumentUid || !right.instrumentUid || left.instrumentUid === right.instrumentUid) && - left.from < right.to && - right.from < left.to + left.from < right.to && + right.from < left.to ); } @@ -78,15 +81,16 @@ export function uncoveredOperationIntervals( !operationIntervalsOverlap(fragment, covered) || (covered.instrumentUid && covered.instrumentUid !== fragment.instrumentUid) - ) + ) { return [fragment]; + } return [ - ...(fragment.from < covered.from - ? [{ ...fragment, to: covered.from }] - : []), - ...(covered.to < fragment.to - ? [{ ...fragment, from: covered.to }] - : []), + ...(fragment.from < covered.from ? + [{ ...fragment, to: covered.from }] : + []), + ...(covered.to < fragment.to ? + [{ ...fragment, from: covered.to }] : + []), ]; }); } @@ -95,8 +99,8 @@ export function uncoveredOperationIntervals( } export type OperationCoverage = OperationInterval & { - fetchedAt: Date; asOf: Date; + fetchedAt: Date; }; const operationsFreshForMs = 300_000; @@ -104,7 +108,7 @@ export function planOperationRead( requested: OperationInterval[], coverage: OperationCoverage[], now: Date, - options: { movingEnd?: boolean; force?: boolean } = {}, + options: { force?: boolean; movingEnd?: boolean } = {}, ) { const monthStart = new Date( Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1), @@ -137,8 +141,9 @@ export function planOperationRead( row.asOf.valueOf() === row.to.valueOf() && row.to > target.from, ) .sort((left, right) => right.to.valueOf() - left.to.valueOf())[0]; - if (latest && latest.to < target.to) + if (latest && latest.to < target.to) { effective = { ...target, to: latest.to }; + } } const missing = uncoveredOperationIntervals([effective], ranges); const complete = missing.length === 0; @@ -163,20 +168,20 @@ export function planOperationRead( // Completeness is bounded by the observed end, never by the new wall clock. return { ...effective, - availability: complete - ? ('complete' as const) - : sources.length - ? ('partial' as const) - : ('missing' as const), + availability: complete ? + ('complete' as const) : + sources.length ? + ('partial' as const) : + ('missing' as const), stale, - asOf: sources.length - ? new Date( + asOf: sources.length ? + new Date( Math.min( effective.to.valueOf(), Math.max(...sources.map((row) => row.asOf.valueOf())), ), - ) - : null, + ) : + null, }; }); return { intervals, load: operationIntervals(load) }; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index 3fe3966..586080e 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -1,10 +1,10 @@ -import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { formatDecimal } from '../../../../common/decimal'; +import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { TbankMoney, TbankOperationItem, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; import { BrokerageOperationDto, OperationCategory, @@ -59,14 +59,14 @@ export function mapBrokerageOperation( logoName: null, payment: money(operation.payment), quantity: - (operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity) === undefined - ? null - : String( - operationCategory(operation.type) === 'trade' - ? operation.quantityDone - : operation.quantity, + (operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity) === undefined ? + null : + String( + operationCategory(operation.type) === 'trade' ? + operation.quantityDone : + operation.quantity, ), price: money(operation.price), accruedInterest: money(operation.accruedInt), @@ -74,22 +74,22 @@ export function mapBrokerageOperation( }; } -function text(value: string | undefined): string | null { +function text(value: string | undefined): null | string { return value?.trim() || null; } function money(value: TbankMoney | undefined) { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } export function operationTimestamp( - value: { seconds?: string | number; nanos?: number } | undefined, + value: undefined | { nanos?: number; seconds?: number | string }, ) { return new Date( Number(value?.seconds ?? 0) * MILLISECONDS_PER_SECOND + - Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), + Math.floor((value?.nanos ?? 0) / NANOSECONDS_PER_MILLISECOND), ); } diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index bd86956..375df64 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -5,46 +5,48 @@ import { } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { TbankOperationItem, TbankOperationsByCursorResponse, } from '../../../../integrations/tbank/tbank.api'; + +import { PrismaService } from '../../../../prisma/prisma.service'; import { - DateOnlyRange, dateOnlyBounds, + DateOnlyRange, moscowDate, } from '../../investment-calendar'; -import { - operationCategory, - operationTimestamp, -} from './brokerage-operation.mapper'; import { type OperationInterval, operationIntervals, operationIntervalsOverlap, uncoveredOperationIntervals, } from '../domain/operations-sync-plan'; +import { + operationCategory, + operationTimestamp, +} from './brokerage-operation.mapper'; export type { OperationInterval } from '../domain/operations-sync-plan'; export type OperationAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; type OperationsIntent = { - intervals: string[]; atomic: boolean; atomicIntervals?: string[]; + intervals: string[]; screenIntervals?: string[]; }; type PageProgress = { complete: boolean; cursor: string; - seen: string[]; pages: number; + seen: string[]; }; const progressItem = ''; @@ -53,17 +55,17 @@ export class BrokerageOperationsRepository { constructor(private readonly prisma: PrismaService) {} async readPage(input: { - userId: string; - revision: string; accountId: string; - from: Date; - to: Date; - instrumentUid?: string; categories: string[]; + cursor?: { accountId: string; at: string; binding: string; id: string }; + filterKey: string; + from: Date; + instrumentUid?: string; limit?: number; periods?: (openedAt: Date | undefined) => DateOnlyRange[]; - filterKey: string; - cursor?: { binding: string; at: string; accountId: string; id: string }; + revision: string; + to: Date; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const connection = await tx.tbankConnection.findUnique({ @@ -92,14 +94,16 @@ export class BrokerageOperationsRepository { connection.membershipAt && input.accountId !== 'all' && !connection.accounts.length - ) + ) { throw new NotFoundException(); + } const accounts = connection.accounts; + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. const oldest = accounts.map((account) => account.openedOn).sort()[0]; const openedAt = - oldest && accounts.every((account) => account.openedOn) - ? new Date(`${oldest}T00:00:00+03:00`) - : undefined; + oldest && accounts.every((account) => account.openedOn) ? + new Date(`${oldest}T00:00:00+03:00`) : + undefined; const periods = input.periods?.(openedAt).map((period) => { const now = new Date(); const bounds = dateOnlyBounds(period, now); @@ -123,8 +127,9 @@ export class BrokerageOperationsRepository { ]), ) .digest('base64url'); - if (input.cursor && input.cursor.binding !== binding) + if (input.cursor && input.cursor.binding !== binding) { throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' }); + } const scope = { userId: input.userId, revision: input.revision, @@ -162,21 +167,21 @@ export class BrokerageOperationsRepository { const rows = await tx.tbankOperation.findMany({ where: { ...scope, - ...(periods - ? { + ...(periods ? + { OR: periods.map((period) => ({ occurredAt: { gte: period.from, lt: period.to }, })), - } - : { occurredAt: { gte: input.from, lt: input.to } }), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid } - : {}), - ...(input.categories.length - ? { category: { in: input.categories } } - : {}), - ...(after - ? { + } : + { occurredAt: { gte: input.from, lt: input.to } }), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid } : + {}), + ...(input.categories.length ? + { category: { in: input.categories } } : + {}), + ...(after ? + { OR: [ { occurredAt: { lt: new Date(after.at) } }, { @@ -189,8 +194,8 @@ export class BrokerageOperationsRepository { brokerOperationId: { gt: after.id }, }, ], - } - : {}), + } : + {}), }, orderBy: [ { occurredAt: 'desc' }, @@ -247,6 +252,7 @@ export class BrokerageOperationsRepository { const scopeKey = 'operations:' + createHash('sha256') + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. .update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort())) .digest('hex'); return this.prisma.$transaction(async (tx) => { @@ -271,14 +277,15 @@ export class BrokerageOperationsRepository { (account) => account.accountId === interval.accountId, ), ) - ) + ) { throw new NotFoundException(); + } if (priority > 1) { for (const account of accounts.filter((account) => intervals.some( (interval) => interval.accountId === account.accountId, ), - )) + )) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -289,13 +296,14 @@ export class BrokerageOperationsRepository { }, data: { historyFrom: new Date( - account.openedOn - ? `${account.openedOn}T00:00:00Z` - : '1970-01-01T00:00:00Z', + account.openedOn ? + `${account.openedOn}T00:00:00Z` : + '1970-01-01T00:00:00Z', ), historyBoundary: account.openedOn ? 'known' : 'unknown', }, }); + } } const coverage = await tx.tbankOperationCoverage.findMany({ where: { @@ -366,42 +374,42 @@ export class BrokerageOperationsRepository { ) { const pending = previousIntent?.intervals.map(decodeInterval) ?? []; const additions = - previous.scopeKey === scopeKey - ? [] - : uncoveredOperationIntervals(intervals, pending); + previous.scopeKey === scopeKey ? + [] : + uncoveredOperationIntervals(intervals, pending); const work = await tx.tbankSyncWork.update({ where: { id: previous.id }, data: { intent: { atomic: previousIntent?.atomic ?? publicationAtomic, - atomicIntervals: previousIntent?.atomic - ? [ + atomicIntervals: previousIntent?.atomic ? + [ ...new Set([ ...(previousIntent.atomicIntervals ?? previousIntent.intervals), ...additions.map(intervalKey), ]), - ] - : [], + ] : + [], screenIntervals: [ ...new Set([ ...(previousIntent?.screenIntervals ?? - (previous.priority === 1 - ? (previousIntent?.intervals ?? []) - : [])), - ...(priority === 1 - ? [...pending, ...additions] + (previous.priority === 1 ? + (previousIntent?.intervals ?? []) : + [])), + ...(priority === 1 ? + [...pending, ...additions] .filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), ), ) - .map(intervalKey) - : []), + .map(intervalKey) : + []), ]), ], - intervals: (priority < previous.priority - ? [ + intervals: (priority < previous.priority ? + [ ...pending.filter((item) => intervals.some((target) => operationIntervalsOverlap(item, target), @@ -414,8 +422,8 @@ export class BrokerageOperationsRepository { operationIntervalsOverlap(item, target), ), ), - ] - : [...pending, ...additions] + ] : + [...pending, ...additions] ).map(intervalKey), }, priority: Math.min(priority, previous.priority), @@ -427,9 +435,9 @@ export class BrokerageOperationsRepository { previous && previous.state !== 'complete' && previous.state !== 'superseded'; - const retained = recovering - ? (previousIntent?.intervals.map(decodeInterval) ?? []) - : []; + const retained = recovering ? + (previousIntent?.intervals.map(decodeInterval) ?? []) : + []; // A bridge between two active groups must fence both producers before // either can publish a subset of the newly combined result. for (const joined of touching) { @@ -448,26 +456,27 @@ export class BrokerageOperationsRepository { }); await tx.tbankSyncStage.deleteMany({ where: { workId: joined.id } }); } - if (previous) + if (previous) { await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); + } const targets = - recovering && !upgradeGroup && touching.length <= 1 - ? previous?.scopeKey === scopeKey - ? retained - : [...retained, ...uncoveredOperationIntervals(intervals, retained)] - : operationIntervals([...retained, ...intervals]); + recovering && !upgradeGroup && touching.length <= 1 ? + previous?.scopeKey === scopeKey ? + retained : + [...retained, ...uncoveredOperationIntervals(intervals, retained)] : + operationIntervals([...retained, ...intervals]); const atomicTargets = [ ...(publicationAtomic ? intervals : []), - ...(recovering && previousIntent?.atomic - ? (previousIntent.atomicIntervals ?? previousIntent.intervals).map( + ...(recovering && previousIntent?.atomic ? + (previousIntent.atomicIntervals ?? previousIntent.intervals).map( decodeInterval, - ) - : []), + ) : + []), ...touching.flatMap((work) => { const joined = work.intent as OperationsIntent; - return joined.atomic - ? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) - : []; + return joined.atomic ? + (joined.atomicIntervals ?? joined.intervals).map(decodeInterval) : + []; }), ]; const intent = { @@ -486,14 +495,14 @@ export class BrokerageOperationsRepository { intervals.some((incoming) => operationIntervalsOverlap(target, incoming), )) || - ( - previousIntent?.screenIntervals ?? - (previous?.priority === 1 - ? (previousIntent?.intervals ?? []) - : []) - ).some((key) => - operationIntervalsOverlap(target, decodeInterval(key)), - ), + ( + previousIntent?.screenIntervals ?? + (previous?.priority === 1 ? + (previousIntent?.intervals ?? []) : + []) + ).some((key) => + operationIntervalsOverlap(target, decodeInterval(key)), + ), ) .map(intervalKey), atomic: atomicTargets.length > 0, @@ -545,20 +554,20 @@ export class BrokerageOperationsRepository { marker.payload as PageProgress, ]), ); - const group = intent.atomic - ? (intent.atomicIntervals ?? intent.intervals) - : intent.intervals; + const group = intent.atomic ? + (intent.atomicIntervals ?? intent.intervals) : + intent.intervals; const nextKey = group.find((key) => !progress.get(key)?.complete); return { priority: work.priority, atomic: intent.atomic, intervals: group.map(decodeInterval), - next: nextKey - ? { + next: nextKey ? + { interval: decodeInterval(nextKey), cursor: progress.get(nextKey)?.cursor || undefined, - } - : null, + } : + null, }; } @@ -582,8 +591,9 @@ export class BrokerageOperationsRepository { updatedAt: true, }, }); - if (!work || work.state !== 'superseded') + if (!work || work.state !== 'superseded') { return work ? { ...work, id } : null; + } target = (work.progress as { successorId: string }).successorId; } throw new Error('Cyclic operations work successor'); @@ -599,7 +609,7 @@ export class BrokerageOperationsRepository { errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE', }, }); - if (invalidToken) + if (invalidToken) { await tx.tbankConnection.update({ where: { userId_revision: { @@ -609,6 +619,7 @@ export class BrokerageOperationsRepository { }, data: { invalidToken: true }, }); + } return true; }); } @@ -636,34 +647,40 @@ export class BrokerageOperationsRepository { }, }); const progress = marker?.payload as PageProgress | undefined; - if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) + if (progress?.complete || (progress?.cursor ?? '') !== (cursor ?? '')) { throw new Error('Unexpected operations page'); - if (response.hasNext === undefined) + } + if (response.hasNext === undefined) { throw new Error('Missing operations pagination state'); + } const nextCursor = response.nextCursor ?? ''; if ( response.hasNext && (!nextCursor || nextCursor === cursor || progress?.seen.includes(nextCursor)) - ) + ) { throw new Error('Invalid operations continuation'); + } for (const operation of response.items ?? []) { - if (!operation.id?.trim()) + if (!operation.id?.trim()) { throw new Error('Missing broker operation identity'); + } const occurredAt = operationTimestamp(operation.date); if ( operation.date?.seconds === undefined || !Number.isFinite(occurredAt.valueOf()) - ) + ) { throw new Error('Invalid operation timestamp'); + } if ( occurredAt < interval.from || occurredAt >= interval.to || (interval.instrumentUid && operation.instrumentUid !== interval.instrumentUid) - ) + ) { continue; + } const payload = JSON.parse( JSON.stringify({ ...operation, cursor: undefined }), ) as Prisma.InputJsonObject; @@ -672,10 +689,12 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankSyncStage.findUnique({ where: { workId_attemptId_sourceKey_itemKey: identity }, }); - if (previous && !isDeepStrictEqual(previous.payload, payload)) + if (previous && !isDeepStrictEqual(previous.payload, payload)) { throw new Error('Conflicting broker operation identity'); - if (!previous) + } + if (!previous) { await tx.tbankSyncStage.create({ data: { ...identity, payload } }); + } } const payload: PageProgress = { complete: !response.hasNext, @@ -712,8 +731,9 @@ export class BrokerageOperationsRepository { fetchedAt: Date, advanceWork = false, ): Promise { - if (!intervals.length) + if (!intervals.length) { throw new Error('Empty operations publication group'); + } return this.prisma.$transaction( async (tx) => { if (!(await claim(tx, attempt))) return false; @@ -734,8 +754,9 @@ export class BrokerageOperationsRepository { }, }, }); - if (!(marker?.payload as PageProgress | undefined)?.complete) + if (!(marker?.payload as PageProgress | undefined)?.complete) { throw new Error('Incomplete operations publication group'); + } const staged = await tx.tbankSyncStage.findMany({ where: { ...stage, itemKey: { not: progressItem } }, }); @@ -745,10 +766,11 @@ export class BrokerageOperationsRepository { if ( previous !== undefined && !isDeepStrictEqual(previous, row.payload) - ) + ) { throw new Error( 'Conflicting broker operation identity across intervals', ); + } groupRows.set(identity, row.payload); } const rows = staged.filter((row) => { @@ -764,16 +786,16 @@ export class BrokerageOperationsRepository { where: { ...scope, occurredAt: { gte: interval.from, lt: interval.to }, - ...(interval.instrumentUid - ? { instrumentUid: interval.instrumentUid } - : {}), + ...(interval.instrumentUid ? + { instrumentUid: interval.instrumentUid } : + {}), }, }); const incoming = new Set(rows.map((row) => row.itemKey)); const removed = existing.filter( (row) => !incoming.has(row.brokerOperationId), ); - for (const row of removed) + for (const row of removed) { await tx.tbankOperation.delete({ where: { userId_revision_accountId_brokerOperationId: { @@ -782,6 +804,7 @@ export class BrokerageOperationsRepository { }, }, }); + } if (removed.length) changedAccounts.add(interval.accountId); for (const row of rows) { const operation = row.payload as TbankOperationItem; @@ -789,8 +812,9 @@ export class BrokerageOperationsRepository { const previous = await tx.tbankOperation.findUnique({ where: { userId_revision_accountId_brokerOperationId: identity }, }); - if (previous && isDeepStrictEqual(previous.payload, operation)) + if (previous && isDeepStrictEqual(previous.payload, operation)) { continue; + } const data = { occurredAt: operationTimestamp(operation.date), instrumentUid: operation.instrumentUid ?? null, @@ -818,7 +842,7 @@ export class BrokerageOperationsRepository { update: { fetchedAt, asOf: interval.to }, }); } - for (const accountId of changedAccounts) + for (const accountId of changedAccounts) { await tx.tbankAccount.update({ where: { userId_revision_accountId: { @@ -829,7 +853,8 @@ export class BrokerageOperationsRepository { }, data: { operationsRevision: { increment: 1 } }, }); - for (const interval of intervals) + } + for (const interval of intervals) { await tx.tbankSyncStage.deleteMany({ where: { workId: attempt.id, @@ -837,6 +862,7 @@ export class BrokerageOperationsRepository { sourceKey: intervalKey(interval), }, }); + } if (advanceWork) { const work = await tx.tbankSyncWork.findUniqueOrThrow({ where: { id: attempt.id }, @@ -867,9 +893,9 @@ export class BrokerageOperationsRepository { intent.screenIntervals ?? (work.priority === 1 ? intent.intervals : []) ).includes(key), - ) - ? 1 - : 2, + ) ? + 1 : + 2, progress: { remainingIntervals: remaining.length }, }, }); @@ -882,8 +908,10 @@ export class BrokerageOperationsRepository { } function intervalKey(interval: OperationInterval): string { - if (!(interval.from < interval.to)) + // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. + if (!(interval.from < interval.to)) { throw new Error('Invalid operations interval'); + } return JSON.stringify([ interval.accountId, interval.from.toISOString(), diff --git a/src/modules/investments/operations/operations.api.ts b/src/modules/investments/operations/operations.api.ts index 69bf304..e197d9d 100644 --- a/src/modules/investments/operations/operations.api.ts +++ b/src/modules/investments/operations/operations.api.ts @@ -1,8 +1,8 @@ -// Public cross-module surface. Nest providers are exported by the matching module. -export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; +export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; export { BrokerageOperationsService } from './application/brokerage-operations.service'; export type { OperationInterval } from './domain/operations-sync-plan'; +// Public cross-module surface. Nest providers are exported by the matching module. +export { BrokerageOperationDto } from './presentation/brokerage-operation.dto'; export { operationCategories } from './presentation/brokerage-operation.dto'; -export { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index 16f37dc..a91bce8 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; +import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; -import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; +import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; @Module({ imports: [AuthModule, TbankModule], diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index 6846422..224bad9 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -1,7 +1,3 @@ -import { - InvestmentReadQueryDto, - InvestmentResourceMetaDto, -} from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { Transform, Type } from 'class-transformer'; import { @@ -16,9 +12,13 @@ import { MaxLength, Min, } from 'class-validator'; -import { MoneyDto } from '../../../../common/money.dto'; +import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { + InvestmentReadQueryDto, + InvestmentResourceMetaDto, +} from '../../investment-resource.dto'; const MAX_CURSOR_LENGTH = 4096; const MAX_OPERATIONS_PAGE_SIZE = 1000; @@ -102,25 +102,25 @@ export class BrokerageOperationDto { declare description: string; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty({ type: String, nullable: true }) - declare instrumentUid: string | null; + declare instrumentUid: null | string; @ApiProperty({ type: String, nullable: true }) - declare ticker: string | null; + declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) - declare instrumentType: string | null; + declare instrumentType: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare payment: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare quantity: string | null; + declare quantity: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare price: MoneyDto | null; @@ -140,7 +140,7 @@ export class BrokerageOperationsPageDto { declare items: BrokerageOperationDto[]; @ApiProperty({ type: String, nullable: true }) - declare nextCursor: string | null; + declare nextCursor: null | string; @ApiProperty() declare hasNext: boolean; @@ -152,6 +152,7 @@ export class BrokerageOperationsPageDto { export class BrokerageOperationsResourceDto { @ApiProperty({ type: BrokerageOperationsPageDto, nullable: true }) declare data: BrokerageOperationsPageDto | null; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 317e4ec..66d1a38 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -1,13 +1,15 @@ +import type { Request } from 'express'; + import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; -import { - BrokerageOperationsResourceDto, - BrokerageOperationsQueryDto, -} from './brokerage-operation.dto'; import { BrokerageOperationsService } from '../application/brokerage-operations.service'; +import { + BrokerageOperationsQueryDto, + BrokerageOperationsResourceDto, +} from './brokerage-operation.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index 2f379d9..389e627 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -1,7 +1,9 @@ import { Injectable } from '@nestjs/common'; + +import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; + import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { investmentResourceMeta } from '../../investment-resource.mapper'; -import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ForecastRepository, type ForecastSourceVersion, @@ -12,16 +14,16 @@ export class ForecastService { constructor(private readonly repository: ForecastRepository) {} async result(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; - portfolio: InvestmentResourceMetaDto; - sources: { - version: ForecastSourceVersion | null; - meta: InvestmentResourceMetaDto; - }[]; candidate?: T; + membershipRevision: number; + portfolio: InvestmentResourceMetaDto; + revision: string; + scopeKey: string; + sources: { + meta: InvestmentResourceMetaDto; + version: ForecastSourceVersion | null; + }[]; + userId: string; }) { const dependencies = [ input.portfolio, @@ -31,8 +33,8 @@ export class ForecastService { ...input, portfolioProjection: input.portfolio.dataRevision, portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) => - dependency.resourceKey.startsWith('portfolio/') - ? [ + dependency.resourceKey.startsWith('portfolio/') ? + [ { accountId: decodeURIComponent( dependency.resourceKey.slice('portfolio/'.length), @@ -41,65 +43,65 @@ export class ForecastService { (dependency as InvestmentResourceMetaDto).dataRevision, ), }, - ] - : [], + ] : + [], ), sources: input.sources.flatMap((source) => source.version ? [source.version] : [], ), timestamps: dependencies.flatMap((dependency) => - dependency.lastSuccessfulAt - ? [ + dependency.lastSuccessfulAt ? + [ { resourceKey: dependency.resourceKey, at: dependency.lastSuccessfulAt, }, - ] - : [], + ] : + [], ), }); const timestamps = stored?.sourceTimestamps as - { resourceKey: string; at: string }[] | undefined; + undefined | { at: string; resourceKey: string }[]; const first = timestamps?.map((entry) => entry.at).sort()[0]; const work = dependencies .map((dependency) => dependency.sync) .find((sync) => sync.state === 'running') ?? - dependencies - .map((dependency) => dependency.sync) - .find((sync) => sync.state === 'failed'); + dependencies + .map((dependency) => dependency.sync) + .find((sync) => sync.state === 'failed'); const payload = stored ? (stored.payload as T) : null; const data = payload && 'positionIsStale' in payload && 'positionAt' in payload && - typeof payload.positionAt === 'string' - ? { + typeof payload.positionAt === 'string' ? + { ...payload, positionIsStale: tbankSourceNeedsRefresh( 'portfolio', new Date(payload.positionAt), new Date(Date.now()), ), - } - : payload; + } : + payload; return { data, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? 0), - availability: stored - ? 'complete' - : dependencies.some( - (dependency) => dependency.availability !== 'missing', - ) - ? 'partial' - : 'missing', + availability: stored ? + 'complete' : + dependencies.some( + (dependency) => dependency.availability !== 'missing', + ) ? + 'partial' : + 'missing', fetchedAt: first ? new Date(first) : null, freshForMs: - data && 'positionIsStale' in data && data.positionIsStale === true - ? 0 - : Number.POSITIVE_INFINITY, + data && 'positionIsStale' in data && data.positionIsStale === true ? + 0 : + Number.POSITIVE_INFINITY, work: work?.id ? { ...work, id: work.id } : null, dependencies, }), diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5c3603b..5df4ec9 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -1,10 +1,10 @@ -import { setImmediate as immediate } from 'node:timers/promises'; import { ServiceUnavailableException } from '@nestjs/common'; +import { setImmediate as immediate } from 'node:timers/promises'; jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); -import { PortfolioService } from './portfolio.service'; import { PortfolioDto } from '../presentation/portfolio.dto'; +import { PortfolioService } from './portfolio.service'; describe('persistent portfolio projection', () => { const access = { local: jest.fn(), tokenForAttempt: jest.fn() }; @@ -103,8 +103,9 @@ describe('persistent portfolio projection', () => { let index = 0; index < 10 && !repository.publish.mock.calls.length; index += 1 - ) + ) { await immediate(); + } const snapshot = ( repository.publish.mock.calls as [unknown, string, PortfolioDto][] )[0][2]; diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index a0aeb79..faad7a6 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -1,17 +1,10 @@ -import { CandleService } from '../../instruments/instruments.api'; -import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; -import { investmentResourceMeta } from '../../investment-resource.mapper'; -import { - PortfolioRepository, - portfolioFreshForMs, - portfolioWorkKey, -} from '../infrastructure/portfolio.repository'; import { Injectable, - ServiceUnavailableException, NotFoundException, + ServiceUnavailableException, } from '@nestjs/common'; +import { randomUUID } from 'node:crypto'; + import { add, decimalFromString, @@ -21,22 +14,30 @@ import { subtract, } from '../../../../common/decimal'; import { RequestContextService } from '../../../../common/logging/request-context.service'; +import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { MoneyDto } from '../../../../common/money.dto'; +import { decimal } from '../../../../integrations/tbank/decimal'; import { InstrumentMetadataService, - metadataKey, - type TbankInstrument, type MetadataIdentity, + metadataKey, TbankAccessService, TbankClient, - TbankMoney, + type TbankInstrument, TbankMembershipService, + TbankMoney, tbankSourceTtlMs, } from '../../../../integrations/tbank/tbank.api'; -import { decimal } from '../../../../integrations/tbank/decimal'; +import { CandleService } from '../../instruments/instruments.api'; +import { investmentResourceMeta } from '../../investment-resource.mapper'; import { portfolioStructure } from '../domain/portfolio-structure'; -import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { sumMoneyByCurrency } from '../domain/portfolio-totals'; +import { + portfolioFreshForMs, + PortfolioRepository, + portfolioWorkKey, +} from '../infrastructure/portfolio.repository'; +import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { PortfolioDto, PortfolioPositionDto, @@ -80,7 +81,7 @@ export class PortfolioService { userId, connection.revision, ); - if (discovery) + if (discovery) { void discovery.done .then(async () => { const current = await this.access.local(userId, accountId); @@ -88,12 +89,13 @@ export class PortfolioService { for (const account of current.accounts.filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, current.revision, account.accountId, ); + } }) .catch(() => this.logger.event( @@ -102,22 +104,25 @@ export class PortfolioService { { userId }, ), ); + } for (const account of (discovery ? [] : connection.accounts).filter( (account) => accountId === 'all' || account.accountId === accountId, - )) + )) { await this.queueSnapshot( userId, connection.revision, account.accountId, ); + } } const saved = await this.repository.read(userId, connection.revision); if ( saved?.membershipAt && accountId !== 'all' && !saved.accounts.some((account) => account.accountId === accountId) - ) + ) { throw new NotFoundException(); + } if (!observe) { const identities = new Map(); for (const account of (saved?.accounts ?? []).filter( @@ -125,17 +130,20 @@ export class PortfolioService { )) { const snapshot = account.portfolio?.payload as unknown as PortfolioDto | undefined; - for (const position of snapshot?.positions ?? []) + for (const position of snapshot?.positions ?? []) { identities.set(metadataKey(position), position); + } } const savedMetadata = await this.metadata.savedMany([ ...identities.values(), ]); - for (const identity of identities.values()) - if (!savedMetadata.get(metadataKey(identity))?.fresh) + for (const identity of identities.values()) { + if (!savedMetadata.get(metadataKey(identity))?.fresh) { await this.metadata.request(userId, connection.revision, identity); + } + } } - if (!observe) + if (!observe) { void this.candles .prefetch( userId, @@ -156,6 +164,7 @@ export class PortfolioService { .catch(() => this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'), ); + } const membershipMeta = investmentResourceMeta({ resourceKey: 'accounts/membership', dataRevision: String(saved?.membershipRevision ?? 0), @@ -175,15 +184,15 @@ export class PortfolioService { name: account.name, type: account.type, openedOn: account.openedOn, - snapshot: account.portfolio - ? { + snapshot: account.portfolio ? + { ...(account.portfolio.payload as unknown as PortfolioDto), lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), isStale: Date.now() - account.portfolio.fetchedAt.valueOf() >= portfolioFreshForMs, - } - : null, + } : + null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, dataRevision: String(account.portfolio?.dataRevision ?? 0), @@ -209,8 +218,8 @@ export class PortfolioService { ); const metadataDependencies = [...metadata.entries()].flatMap( ([key, source]) => - source.complete && source.data - ? [ + source.complete && source.data ? + [ investmentResourceMeta({ resourceKey: `instrument/metadata/${key}`, dataRevision: source.dataRevision, @@ -219,20 +228,20 @@ export class PortfolioService { freshForMs: tbankSourceTtlMs.metadata, work: null, }), - ] - : [], + ] : + [], ); const portfolios = saved.accounts.flatMap((account) => - account.snapshot - ? [ + account.snapshot ? + [ { ...account.snapshot, positions: account.snapshot.positions.map((position) => { const shared = metadata.get(metadataKey(position))?.data; return { ...position, - ...(shared - ? { + ...(shared ? + { displayName: shared.name?.trim() || shared.ticker?.trim() || @@ -241,28 +250,29 @@ export class PortfolioService { isin: shared.isin?.trim() || position.isin, logoName: shared.brand?.logoName?.trim() || position.logoName, - } - : {}), - ...(accountId === 'all' - ? { account: { id: account.id, name: account.name } } - : {}), + } : + {}), + ...(accountId === 'all' ? + { account: { id: account.id, name: account.name } } : + {}), }; }), }, - ] - : [], + ] : + [], ); const lastSuccessfulAt = + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? saved.membership.lastSuccessfulAt; const data = - missing || !lastSuccessfulAt - ? null - : { + missing || !lastSuccessfulAt ? + null : + { lastSuccessfulAt, - isStale: portfolios.length - ? portfolios.some((portfolio) => portfolio.isStale) - : saved.membership.freshness !== 'fresh', + isStale: portfolios.length ? + portfolios.some((portfolio) => portfolio.isStale) : + saved.membership.freshness !== 'fresh', totalsByCurrency: sumMoneyByCurrency( portfolios.flatMap((portfolio) => portfolio.totalsByCurrency), ), @@ -279,10 +289,10 @@ export class PortfolioService { saved.accounts .map((account) => account.meta.sync) .find((work) => work.state === 'running') ?? - saved.accounts - .map((account) => account.meta.sync) - .find((work) => work.state === 'failed') ?? - saved.membership.sync; + saved.accounts + .map((account) => account.meta.sync) + .find((work) => work.state === 'failed') ?? + saved.membership.sync; return { data, meta: investmentResourceMeta({ @@ -294,15 +304,15 @@ export class PortfolioService { ]), metadataDependencies.map((dependency) => dependency.dataRevision), ]), - availability: missing - ? portfolios.length - ? 'partial' - : 'missing' - : 'complete', + availability: missing ? + portfolios.length ? + 'partial' : + 'missing' : + 'complete', fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, - freshForMs: portfolios.length - ? portfolioFreshForMs - : membershipFreshForMs, + freshForMs: portfolios.length ? + portfolioFreshForMs : + membershipFreshForMs, work: work.id ? { ...work, id: work.id } : null, dependencies: [ saved.membership, @@ -333,11 +343,12 @@ export class PortfolioService { force, ); if (!requested) return null; - if (!requested.launch) + if (!requested.launch) { return { id: requested.work.id, done: this.pending.get(requested.work.attemptId) ?? Promise.resolve(), }; + } const { id, attemptId } = requested.work; this.active.add(attemptId); const done = new Promise((resolve) => setImmediate(resolve)) @@ -360,22 +371,24 @@ export class PortfolioService { snapshot, ); if (published) { - for (const position of snapshot.positions) + for (const position of snapshot.positions) { await this.metadata.request(userId, revision, position); - if (!force) + } + if (!force) { await this.candles.prefetch( userId, revision, snapshot.positions, ); + } } } catch (error) { if ( error instanceof Error && error.message === 'INVALID_TBANK_TOKEN' - ) + ) { await this.access.rejectMembershipToken(attempt); - else await this.repository.fail(attempt); + } else await this.repository.fail(attempt); } }) .catch(() => @@ -396,8 +409,9 @@ export class PortfolioService { async get(userId: string, accountId: string): Promise { const result = await this.read(userId, accountId); - if (!result.data) + if (!result.data) { throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); + } return result.data; } @@ -407,12 +421,12 @@ export class PortfolioService { private async normalize( upstream: { - figi?: string; - instrumentUid?: string; - instrumentType?: string; - quantity?: { units?: string | number; nano?: string | number }; - currentPrice?: TbankMoney; averagePositionPrice?: TbankMoney; + currentPrice?: TbankMoney; + figi?: string; + instrumentType?: string; + instrumentUid?: string; + quantity?: { nano?: number | string; units?: number | string }; }[], money: TbankMoney[], ): Promise { @@ -450,8 +464,8 @@ export class PortfolioService { private position( position: Parameters[0][number], - metadata: TbankInstrument | null, - ): PortfolioPositionDto | null { + metadata: null | TbankInstrument, + ): null | PortfolioPositionDto { const quantity = decimal(position.quantity ?? {}); if (isZero(quantity)) return null; const figi = position.figi?.trim() || null; @@ -462,8 +476,8 @@ export class PortfolioService { const absoluteReturn = currentPrice && averagePurchasePrice && - currentPrice.currency === averagePurchasePrice.currency - ? { + currentPrice.currency === averagePurchasePrice.currency ? + { currency: currentPrice.currency, value: formatDecimal( multiply( @@ -474,8 +488,8 @@ export class PortfolioService { quantity, ), ), - } - : null; + } : + null; return { figi, instrumentUid, @@ -507,8 +521,8 @@ export class PortfolioService { private money(value: TbankMoney | undefined): MoneyDto | null { const currency = value?.currency?.trim().toUpperCase(); - return currency && value - ? { currency, value: formatDecimal(decimal(value)) } - : null; + return currency && value ? + { currency, value: formatDecimal(decimal(value)) } : + null; } } diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts index 83cbe50..4264872 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.spec.ts @@ -1,9 +1,9 @@ -import { portfolioStructure } from './portfolio-structure'; import { PortfolioPositionDto } from '../presentation/portfolio.dto'; +import { portfolioStructure } from './portfolio-structure'; const position = ( type: string, - value: string | null, + value: null | string, currency = 'RUB', ): PortfolioPositionDto => ({ type, diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 514f167..c6e749d 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -1,11 +1,11 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; - const SHARE_PERCENT_DECIMAL_PLACES = 6; const PERCENT_SCALED_FACTOR = 100_000_000n; @@ -13,14 +13,14 @@ const categoryTypes = ['STOCK', 'BOND', 'OTHER', 'CASH'] as const; // Build from positions and cash, never from totals (which already include cash). export function portfolioStructure( - positions: { type: string; currentValue: Money | null }[], + positions: { currentValue: Money | null; type: string }[], cash: Money[], ) { const currencies = new Map< string, { - values: ReturnType[]; hasNegativeValues: boolean; + values: ReturnType[]; } >(); const unvalued = new Map(); @@ -31,14 +31,15 @@ export function portfolioStructure( }; const index = categoryTypes.findIndex((category) => category === type); const value = decimalFromString(money.value); + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. group.values[index] = add(group.values[index], value); group.hasNegativeValues ||= value.value < 0n; currencies.set(money.currency, group); }; for (const position of positions) { - const type = ['STOCK', 'BOND'].includes(position.type) - ? position.type - : 'OTHER'; + const type = ['BOND', 'STOCK'].includes(position.type) ? + position.type : + 'OTHER'; if (position.currentValue) include(type, position.currentValue); else unvalued.set(type, (unvalued.get(type) ?? 0) + 1); } @@ -53,21 +54,22 @@ export function portfolioStructure( const canCalculateShares = !unvalued.size && !group.hasNegativeValues && total.value > 0n; const categories = group.values.map((value, index) => ({ + // eslint-disable-next-line security/detect-object-injection -- The numeric index belongs to the fixed portfolio category array. type: categoryTypes[index], value: formatDecimal(value), // Rounded to six decimal places using integer arithmetic. Only the // resulting dimensionless percentage is passed to chart geometry. - sharePercent: canCalculateShares - ? formatDecimal({ + sharePercent: canCalculateShares ? + formatDecimal({ value: (value.value * 10n ** BigInt(total.scale - value.scale) * PERCENT_SCALED_FACTOR + total.value / 2n) / - total.value, + total.value, scale: SHARE_PERCENT_DECIMAL_PLACES, - }) - : null, + }) : + null, })); return { currency, diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts index e38548c..da81680 100644 --- a/src/modules/investments/portfolio/domain/portfolio-totals.ts +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -1,9 +1,10 @@ +import type { Money } from '../../../../common/money.types'; + import { add, decimalFromString, formatDecimal, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; export function sumMoneyByCurrency(amounts: Money[]): Money[] { const totals = new Map>(); diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index ae95b21..714192b 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -1,15 +1,17 @@ import { Injectable } from '@nestjs/common'; import { isDeepStrictEqual } from 'node:util'; + import type { Prisma } from '../../../../../generated/prisma/client'; + import { PrismaService } from '../../../../prisma/prisma.service'; export type ForecastSourceVersion = { - instrumentUid: string; - source: string; - kind: string; - rangeKey: string; dataRevision: number; fetchedAt: Date; + instrumentUid: string; + kind: string; + rangeKey: string; + source: string; }; const forecastSourceReadBatchSize = 100; @@ -18,15 +20,15 @@ export class ForecastRepository { constructor(private readonly prisma: PrismaService) {} resolve(input: { - userId: string; - revision: string; - membershipRevision: number; - scopeKey: string; candidate?: object; + membershipRevision: number; portfolioProjection: string; portfolios: { accountId: string; dataRevision: number }[]; + revision: string; + scopeKey: string; sources: ForecastSourceVersion[]; - timestamps: { resourceKey: string; at: string }[]; + timestamps: { at: string; resourceKey: string }[]; + userId: string; }) { return this.prisma.$transaction(async (tx) => { const owner = { userId: input.userId, revision: input.revision }; @@ -54,9 +56,9 @@ export class ForecastRepository { where: { userId_revision_scopeKey: key }, }); const fallback = - previous?.membershipRevision === input.membershipRevision - ? previous - : null; + previous?.membershipRevision === input.membershipRevision ? + previous : + null; if (!input.candidate) return fallback; const portfolios = await tx.tbankPortfolio.findMany({ where: { @@ -74,15 +76,16 @@ export class ForecastRepository { row.dataRevision === expected.dataRevision, ), ) - ) + ) { return fallback; + } // Four identity parameters per source: stay below SQLite's parameter limit. const sources: ForecastSourceVersion[] = []; for ( let offset = 0; offset < input.sources.length; offset += forecastSourceReadBatchSize - ) + ) { sources.push( ...(await tx.tbankInstrumentSource.findMany({ where: { @@ -106,6 +109,7 @@ export class ForecastRepository { }, })), ); + } if ( input.sources.some( (expected) => @@ -119,8 +123,9 @@ export class ForecastRepository { row.fetchedAt.valueOf() === expected.fetchedAt.valueOf(), ), ) - ) + ) { return fallback; + } const revisions = JSON.parse( JSON.stringify({ portfolioProjection: input.portfolioProjection, @@ -133,8 +138,9 @@ export class ForecastRepository { fallback && isDeepStrictEqual(fallback.dependencyRevisions, revisions) && isDeepStrictEqual(fallback.sourceTimestamps, timestamps) - ) + ) { return fallback; + } const data = { membershipRevision: input.membershipRevision, payload: JSON.parse( diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index b7ffe04..8144fd0 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -1,18 +1,20 @@ -import { - tbankSourceTtlMs, - tbankSourceNeedsRefresh, -} from '../../../../integrations/tbank/tbank.api'; import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; + import type { Prisma } from '../../../../../generated/prisma/client'; import type { PortfolioDto } from '../presentation/portfolio.dto'; +import { + tbankSourceNeedsRefresh, + tbankSourceTtlMs, +} from '../../../../integrations/tbank/tbank.api'; +import { PrismaService } from '../../../../prisma/prisma.service'; + type PortfolioAttempt = { - id: string; attemptId: string; - userId: string; + id: string; revision: string; + userId: string; }; export const portfolioFreshForMs = tbankSourceTtlMs.portfolio; @@ -71,8 +73,9 @@ export class PortfolioRepository { !force && snapshot && !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) - ) + ) { return null; + } const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, @@ -81,8 +84,9 @@ export class PortfolioRepository { previous?.state === 'running' && previous.processRunId === processRunId && active.has(previous.attemptId) - ) + ) { return { work: previous, launch: false }; + } const data = { state: 'running', attemptId: randomUUID(), diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index fd01dc4..f4ae5b6 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -1,12 +1,12 @@ +import type { Money } from '../../../../common/money.types'; + import { add, formatDecimal, isZero, multiply, } from '../../../../common/decimal'; -import type { Money } from '../../../../common/money.types'; import { decimal } from '../../../../integrations/tbank/decimal'; - import { TbankPortfolio, TbankPositions, diff --git a/src/modules/investments/portfolio/portfolio.api.ts b/src/modules/investments/portfolio/portfolio.api.ts index eb62409..fc567da 100644 --- a/src/modules/investments/portfolio/portfolio.api.ts +++ b/src/modules/investments/portfolio/portfolio.api.ts @@ -1,8 +1,8 @@ +export { ForecastService } from './application/forecast.service'; +export { PortfolioService } from './application/portfolio.service'; + // Public cross-module surface. Nest providers are exported by the matching module. export { PortfolioDto, PortfolioPositionDto, } from './presentation/portfolio.dto'; -export { PortfolioService } from './application/portfolio.service'; - -export { ForecastService } from './application/forecast.service'; diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 5f77166..6b462b2 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -1,12 +1,13 @@ -import { ForecastService } from './application/forecast.service'; -import { ForecastRepository } from './infrastructure/forecast.repository'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioRepository } from './infrastructure/portfolio.repository'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; -import { PortfolioController } from './presentation/portfolio.controller'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { ForecastService } from './application/forecast.service'; import { PortfolioService } from './application/portfolio.service'; +import { ForecastRepository } from './infrastructure/forecast.repository'; +import { PortfolioRepository } from './infrastructure/portfolio.repository'; +import { PortfolioController } from './presentation/portfolio.controller'; @Module({ imports: [AuthModule, TbankModule, InstrumentSourcesModule], diff --git a/src/modules/investments/portfolio/presentation/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts index 942dd6a..573b696 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -1,11 +1,13 @@ -import { InvestmentReadQueryDto } from '../../investment-resource.dto'; -import { Controller, Get, Param, Req, Query, UseGuards } from '@nestjs/common'; -import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import type { Request } from 'express'; + +import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; + import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; -import { PortfolioResourceDto } from './portfolio.dto'; +import { InvestmentReadQueryDto } from '../../investment-resource.dto'; import { PortfolioService } from '../application/portfolio.service'; +import { PortfolioResourceDto } from './portfolio.dto'; @ApiTags('brokerage-accounts') @ApiProblemResponses() diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index a7a8224..a2bcde6 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -1,17 +1,18 @@ -import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + import { MoneyDto } from '../../../../common/money.dto'; import { AccountReferenceDto } from '../../account-reference.dto'; +import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; export class PortfolioPositionDto { @ApiPropertyOptional({ type: String, nullable: true }) - instrumentUid?: string | null; + instrumentUid?: null | string; @ApiPropertyOptional({ type: AccountReferenceDto }) account?: AccountReferenceDto; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty() declare displayName: string; @@ -44,13 +45,13 @@ export class PortfolioPositionDto { declare absoluteReturn: MoneyDto | null; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare ticker: string | null; + declare ticker: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; } export class PortfolioStructureCategoryDto { @@ -66,7 +67,7 @@ export class PortfolioStructureCategoryDto { description: 'Percentage including cash, rounded to six decimal places. Null for incomplete, negative or zero-total compositions.', }) - declare sharePercent: string | null; + declare sharePercent: null | string; } export class PortfolioStructureCurrencyDto { @@ -81,7 +82,7 @@ export class PortfolioStructureCurrencyDto { }) declare hasNegativeValues: boolean; - @ApiProperty({ type: [PortfolioStructureCategoryDto] }) + @ApiProperty({ type: PortfolioStructureCategoryDto, isArray: true }) declare categories: PortfolioStructureCategoryDto[]; } @@ -94,11 +95,11 @@ export class PortfolioUnvaluedCategoryDto { } export class PortfolioStructureDto { - @ApiProperty({ type: [PortfolioStructureCurrencyDto] }) + @ApiProperty({ type: PortfolioStructureCurrencyDto, isArray: true }) declare currencies: PortfolioStructureCurrencyDto[]; @ApiProperty({ - type: [PortfolioUnvaluedCategoryDto], + type: PortfolioUnvaluedCategoryDto, isArray: true, description: 'Positions without current value and known valuation currency. Shares are unavailable across all currencies when nonempty.', }) @@ -127,7 +128,8 @@ export class PortfolioDto { export class PortfolioResourceDto { @ApiProperty({ type: PortfolioDto, nullable: true }) - declare data: PortfolioDto | null; + declare data: null | PortfolioDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts index 4e7943b..6d94617 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -1,3 +1,5 @@ +import type { Request } from 'express'; + import { Body, Controller, @@ -15,7 +17,7 @@ import { ApiOkResponse, ApiTags, } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { diff --git a/src/modules/investments/tbank-connection/tbank-connection.dto.ts b/src/modules/investments/tbank-connection/tbank-connection.dto.ts index fd0dcb8..e51b955 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.dto.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.dto.ts @@ -16,5 +16,5 @@ export class TbankConnectionStatusDto { declare connected: boolean; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) - declare connectedAt: string | null; + declare connectedAt: null | string; } diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts index 2f6de7f..b393571 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.module.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -1,4 +1,5 @@ import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; import { TbankConnectionController } from './tbank-connection.controller'; diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index a048821..9bd15ad 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,8 +1,9 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; + import { + supportedTbankAccounts, TbankClient, TbankTokenService, - supportedTbankAccounts, } from '../../../integrations/tbank/tbank.api'; import { PrismaService } from '../../../prisma/prisma.service'; diff --git a/src/modules/investments/tbank-sync/tbank-screen.spec.ts b/src/modules/investments/tbank-sync/tbank-screen.spec.ts index c138b23..40bfccf 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.spec.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.spec.ts @@ -40,8 +40,9 @@ describe('manual sync screen boundary', () => { from: '2026-02-01', to: '2026-01-01', }, - ] as Parameters[0][]) + ] as Parameters[0][]) { expect(() => normalizeScreen(scope)).toThrow(); + } }); it('retains inclusive calendar days, rejecting instant values for date-only owners', () => { const scope = { diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index eb5a363..e26d79f 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -1,4 +1,5 @@ import { BadRequestException } from '@nestjs/common'; + import type { InvestmentScreenDto } from './tbank-sync.dto'; // Reject fields from other screens before reserving any source work. @@ -41,68 +42,78 @@ export function normalizeScreen( }); }; if (!allowed[input.screen]) fail(); - for (const key of Object.keys(input)) + for (const key of Object.keys(input)) { if ( key !== 'screen' && input[key as keyof InvestmentScreenDto] !== undefined && !allowed[input.screen].includes(key) - ) + ) { fail(); + } + } const accountId = input.accountId?.trim(); const query = input.query?.trim().toUpperCase(); if ( - ['account', 'operations', 'income', 'events'].includes(input.screen) && + ['account', 'events', 'income', 'operations'].includes(input.screen) && !accountId - ) + ) { fail(); - if ( - ['stocks', 'bonds'].includes(input.screen) && - (!query || query.length < 2) - ) - fail(); - if (['stock', 'bond'].includes(input.screen) && !input.instrumentUid) fail(); - if (input.accountId !== undefined && !accountId) fail(); - if (['income', 'events'].includes(input.screen)) { - if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to)) - fail(); - for (const value of [input.from, input.to]) - if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail(); } - if (input.from && input.to && new Date(input.from) > new Date(input.to)) + if ( + ['bonds', 'stocks'].includes(input.screen) && + (!query || query.length < 2) + ) { fail(); + } + if (['bond', 'stock'].includes(input.screen) && !input.instrumentUid) fail(); + if (input.accountId !== undefined && !accountId) fail(); + if (['events', 'income'].includes(input.screen)) { + if (input.screen === 'income' && Boolean(input.from) !== Boolean(input.to)) { + fail(); + } + for (const value of [input.from, input.to]) { + if (value && !/^\d{4}-\d{2}-\d{2}$/.test(value)) fail(); + } + } + if (input.from && input.to && new Date(input.from) > new Date(input.to)) { + fail(); + } if ( input.screen === 'events' && input.to && (input.from ?? new Date().toISOString().slice(0, 10)) > input.to - ) + ) { fail(); + } if ( input.candleRange && new Date(input.candleRange.from) >= new Date(input.candleRange.to) - ) + ) { fail(); + } // Fixed field order is part of coalescing, irrespective of JSON input order. return { screen: input.screen, ...(accountId ? { accountId } : {}), ...(input.from ? { from: input.from } : {}), ...(input.to ? { to: input.to } : {}), - ...(input.instrumentUid - ? { instrumentUid: input.instrumentUid.toLowerCase() } - : {}), + ...(input.instrumentUid ? + { instrumentUid: input.instrumentUid.toLowerCase() } : + {}), ...(query ? { query } : {}), ...(input.candlePeriod ? { candlePeriod: input.candlePeriod } : {}), - ...(input.candleRange - ? { + ...(input.candleRange ? + { candleRange: { from: new Date(input.candleRange.from).toISOString(), to: new Date(input.candleRange.to).toISOString(), }, - } - : {}), - ...(input.categories - ? { categories: [...new Set(input.categories)].sort() } - : {}), + } : + {}), + ...(input.categories ? + // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. + { categories: [...new Set(input.categories)].sort() } : + {}), ...(input.limit !== undefined ? { limit: input.limit } : {}), }; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts index c4985c0..4d9bfcf 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.controller.ts @@ -1,6 +1,8 @@ +import type { Request } from 'express'; + import { - Body, BadRequestException, + Body, Controller, Get, HttpCode, @@ -19,17 +21,17 @@ import { ApiOkResponse, ApiTags, } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { - InvestmentScreenDto, - IncomeInvestmentScreenDto, EventsInvestmentScreenDto, - TimestampInvestmentScreenDto, + IncomeInvestmentScreenDto, + InvestmentScreenDto, investmentScreenSchema, TbankSyncAcceptedDto, TbankSyncStatusDto, + TimestampInvestmentScreenDto, } from './tbank-sync.dto'; import { TbankSyncService } from './tbank-sync.service'; @@ -53,6 +55,7 @@ export class TbankSyncController { @Req() request: Request & { user: AccessPayload }, @Body( new ValidationPipe({ + forbidUnknownValues: true, expectedType: InvestmentScreenDto, transform: true, whitelist: true, @@ -65,6 +68,7 @@ export class TbankSyncController { ) { return this.sync.request(request.user.sub, input as InvestmentScreenDto); } + @Get(':id') @ApiOkResponse({ type: TbankSyncStatusDto }) status( diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index 011c9e7..5f5c467 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -20,6 +20,7 @@ import { MinLength, ValidateNested, } from 'class-validator'; + import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { operationCategories } from '../operations/operations.api'; @@ -46,12 +47,14 @@ export class InvestmentScreenDto { @ApiProperty({ enum: investmentScreens }) @IsIn(investmentScreens) declare screen: (typeof investmentScreens)[number]; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(1) @MaxLength(maxAccountIdLength) accountId?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -61,6 +64,7 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) from?: string; + @ApiPropertyOptional({ oneOf: [ { type: 'string', format: 'date' }, @@ -70,30 +74,36 @@ export class InvestmentScreenDto { @IsOptional() @IsDateString({ strict: true }) to?: string; + @ApiPropertyOptional({ format: 'uuid' }) @IsOptional() @IsUUID() instrumentUid?: string; + @ApiPropertyOptional() @IsOptional() @IsString() @MinLength(2) @MaxLength(maxQueryLength) query?: string; + @ApiPropertyOptional({ enum: ['month', 'halfYear', 'year', 'all'] }) @IsOptional() @IsIn(['month', 'halfYear', 'year', 'all']) - candlePeriod?: 'month' | 'halfYear' | 'year' | 'all'; + candlePeriod?: 'all' | 'halfYear' | 'month' | 'year'; + @ApiPropertyOptional({ type: SyncCandleRangeDto }) @IsOptional() @ValidateNested() @Type(() => SyncCandleRangeDto) candleRange?: SyncCandleRangeDto; + @ApiPropertyOptional({ enum: operationCategories, isArray: true }) @IsOptional() @IsArray() @IsIn(operationCategories, { each: true }) categories?: (typeof operationCategories)[number][]; + @ApiPropertyOptional({ minimum: 1, maximum: maxPageSize }) @IsOptional() @IsInt() @@ -106,6 +116,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['income'] }) declare screen: 'income'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -113,6 +124,7 @@ export class IncomeInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'Inclusive start calendar date in Europe/Moscow (YYYY-MM-DD). Supply both from and to, or neither for the current Moscow month.', }) from?: string; + @ApiPropertyOptional({ format: 'date', description: @@ -125,6 +137,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'accountId', ]) { @ApiProperty({ enum: ['events'] }) declare screen: 'events'; + // eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional metadata inherited through PickType. @ApiProperty({ required: true }) declare accountId: string; @ApiPropertyOptional({ format: 'date', @@ -132,6 +145,7 @@ export class EventsInvestmentScreenDto extends PickType(InvestmentScreenDto, [ 'Inclusive UTC calendar start date (YYYY-MM-DD). May be supplied independently; defaults to today in UTC.', }) from?: string; + @ApiPropertyOptional({ format: 'date', description: @@ -157,11 +171,13 @@ export class TimestampInvestmentScreenDto extends OmitType( // The runtime validator retains legacy date-only inputs; the public contract uses instants. @ApiProperty({ enum: timestampScreens }) declare screen: (typeof timestampScreens)[number]; + @ApiPropertyOptional({ format: 'date-time', description: 'Start instant for operations.', }) from?: string; + @ApiPropertyOptional({ format: 'date-time', description: 'End instant for operations.', @@ -194,6 +210,7 @@ export class TbankSyncAcceptedDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema }) declare scope: InvestmentScreenDto; + @ApiProperty({ format: 'date-time' }) declare now: string; } export class TbankSyncProgressDto { @@ -205,13 +222,17 @@ export class TbankSyncStatusDto { @ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) declare scope: InvestmentScreenDto | null; + @ApiProperty({ format: 'date-time' }) declare now: string; @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) - declare state: 'running' | 'succeeded' | 'failed' | 'cancelled'; + declare state: 'cancelled' | 'failed' | 'running' | 'succeeded'; + @ApiProperty({ type: String, nullable: true }) declare errorCode: - string | null; + null | string; + @ApiProperty({ type: TbankSyncProgressDto }) declare progress: TbankSyncProgressDto; + @ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) declare resources: InvestmentResourceMetaDto[]; } diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index b963c81..32e5d3d 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -1,16 +1,17 @@ import { Module } from '@nestjs/common'; -import { AuthModule } from '../../auth/auth.module'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; -import { OperationsModule } from '../operations/operations.module'; -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { InstrumentsModule } from '../instruments/instruments.module'; +import { AuthModule } from '../../auth/auth.module'; import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module'; import { IncomeModule } from '../income/income.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { InstrumentsModule } from '../instruments/instruments.module'; +import { OperationsModule } from '../operations/operations.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncRepository } from './tbank-sync.repository'; +import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ AuthModule, diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index 5add3a8..8189dd0 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -1,13 +1,15 @@ import { Injectable } from '@nestjs/common'; import { createHash, randomUUID } from 'node:crypto'; + import type { Prisma } from '../../../../generated/prisma/client'; -import { PrismaService } from '../../../prisma/prisma.service'; import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { InvestmentScreenDto } from './tbank-sync.dto'; -export type ScreenIntent = { scope: InvestmentScreenDto; now: string }; + +import { PrismaService } from '../../../prisma/prisma.service'; +export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; export type ScreenProgress = { - children: string[]; attempts?: Record; + children: string[]; dispatched: boolean; }; @Injectable() @@ -38,8 +40,9 @@ export class TbankSyncRepository { (await tx.tbankSyncWork.count({ where: { userId, revision, id: { in: children }, state: 'running' }, })) - ) + ) { return { work: previous, launch: false }; + } } const data = { attemptId: randomUUID(), @@ -60,6 +63,7 @@ export class TbankSyncRepository { return { work, launch: true }; }); } + read(userId: string, revision: string, id: string) { return this.prisma.$transaction(async (tx) => { const work = await tx.tbankSyncWork.findFirst({ @@ -75,19 +79,20 @@ export class TbankSyncRepository { work, children: children.map((child) => progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId - ? { ...child, state: 'cancelled', errorCode: null } - : child, + progress.attempts[child.id] !== child.attemptId ? + { ...child, state: 'cancelled', errorCode: null } : + child, ), progress, }; }); } + async progress( attempt: TbankSyncAttempt, children: string[], dispatched: boolean, - errorCode: string | null = null, + errorCode: null | string = null, ) { return this.prisma.$transaction(async (tx) => { const previous = await tx.tbankSyncWork.findFirst({ where: attempt }); diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts index 826830c..52f4668 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.service.ts @@ -1,43 +1,45 @@ import { - Injectable, ConflictException, + Injectable, NotFoundException, ServiceUnavailableException, } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; + +import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; + import { + InstrumentMetadataService, TbankAccessService, TbankMembershipService, - InstrumentMetadataService, type TbankSyncAttempt, } from '../../../integrations/tbank/tbank.api'; -import { PortfolioService } from '../portfolio/portfolio.api'; +import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; +import { IncomeAnalyticsService } from '../income/income-read.api'; import { - BrokerageOperationsService, - BrokerageOperationsSyncService, - type OperationInterval, -} from '../operations/operations.api'; + BondIncomeService, + StockIncomeService, +} from '../instruments/instrument-income.api'; import { CandleService, CatalogDiscoveryService, CatalogEnrichmentService, } from '../instruments/instruments.api'; -import { - StockIncomeService, - BondIncomeService, -} from '../instruments/instrument-income.api'; -import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api'; -import { IncomeAnalyticsService } from '../income/income-read.api'; -import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; -import { planInvestmentScreen } from '../investment-sync-plan'; -import type { InvestmentResourceMetaDto } from '../investment-resource.dto'; -import { - TbankSyncRepository, - type ScreenIntent, -} from './tbank-sync.repository'; -import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; -import { normalizeScreen } from './tbank-screen'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { planInvestmentScreen } from '../investment-sync-plan'; +import { + BrokerageOperationsService, + BrokerageOperationsSyncService, + type OperationInterval, +} from '../operations/operations.api'; +import { PortfolioService } from '../portfolio/portfolio.api'; +import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api'; +import { normalizeScreen } from './tbank-screen'; +import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; +import { + type ScreenIntent, + TbankSyncRepository, +} from './tbank-sync.repository'; @Injectable() export class TbankSyncService { @@ -88,7 +90,7 @@ export class TbankSyncService { private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) { const children: string[] = []; - const add = async (ids: (string | null | undefined)[]) => { + const add = async (ids: (null | string | undefined)[]) => { children.push(...ids.filter((id): id is string => Boolean(id))); await this.repository.progress(attempt, children, false); }; @@ -107,14 +109,14 @@ export class TbankSyncService { accountScope === 'all' || account.accountId === accountScope, ); if ( - ['accounts', 'account', 'events', 'stock', 'bond'].includes( + ['account', 'accounts', 'bond', 'events', 'stock'].includes( scope.screen, ) ) { for (const account of scope.screen === 'stock' || - scope.screen === 'bond' - ? current.accounts - : selectedAccounts) { + scope.screen === 'bond' ? + current.accounts : + selectedAccounts) { const work = await this.portfolio.request( userId, revision, @@ -127,9 +129,9 @@ export class TbankSyncService { } const saved = await this.portfolio.read( userId, - scope.screen === 'stock' || scope.screen === 'bond' - ? 'all' - : accountScope, + scope.screen === 'stock' || scope.screen === 'bond' ? + 'all' : + accountScope, true, ); const positions = saved.data?.positions ?? []; @@ -137,8 +139,8 @@ export class TbankSyncService { ...new Map( positions.flatMap((position) => position.instrumentUid && - (position.type === 'STOCK' || position.type === 'BOND') - ? [ + (position.type === 'STOCK' || position.type === 'BOND') ? + [ [ position.instrumentUid, { @@ -147,27 +149,27 @@ export class TbankSyncService { historyFrom: null, }, ] as const, - ] - : [], + ] : + [], ), ).values(), ]; const plan = planInvestmentScreen( { ...scope, - candleRange: scope.candleRange - ? { + candleRange: scope.candleRange ? + { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), - } - : undefined, + } : + undefined, }, { accounts: selectedAccounts, instruments: - scope.screen === 'stocks' || scope.screen === 'bonds' - ? [] - : instruments, + scope.screen === 'stocks' || scope.screen === 'bonds' ? + [] : + instruments, membershipKnown: Boolean(current.membershipAt), }, now, @@ -177,20 +179,20 @@ export class TbankSyncService { source.kind === 'operations' && source.accountId && source.from && - source.to - ? [ + source.to ? + [ { accountId: source.accountId, from: source.from, to: source.to, - ...(source.instrumentUid - ? { instrumentUid: source.instrumentUid } - : {}), + ...(source.instrumentUid ? + { instrumentUid: source.instrumentUid } : + {}), }, - ] - : [], + ] : + [], ); - if (intervals.length) + if (intervals.length) { await add([ await this.operations.request( userId, @@ -200,24 +202,27 @@ export class TbankSyncService { revision, ), ]); + } for (const source of plan.sources) { const uid = source.instrumentUid; const type = source.instrumentType === 'BOND' ? 'bond' : 'share'; - if (source.kind === 'catalog') + if (source.kind === 'catalog') { await add( await this.discovery.refresh(userId, revision, { instrumentType: type, query: source.query ?? '', }), ); + } if (!uid) continue; - if (source.kind === 'metadata') + if (source.kind === 'metadata') { await add([ await this.metadata.request(userId, revision, { instrumentUid: uid, }), ]); - if (source.kind === 'quote') + } + if (source.kind === 'quote') { await add( await this.sources.requestMany( userId, @@ -233,7 +238,8 @@ export class TbankSyncService { ], ), ); - if (source.kind === 'company') + } + if (source.kind === 'company') { await add( await this.sources.requestMany( userId, @@ -244,7 +250,8 @@ export class TbankSyncService { ['description', 'fundamentals'], ), ); - if (source.kind === 'metrics') + } + if (source.kind === 'metrics') { await add( await this.sources.requestMany( userId, @@ -255,7 +262,8 @@ export class TbankSyncService { ['coupons', 'yield', 'rating'], ), ); - if (source.kind === 'calendar' && source.from && source.to) + } + if (source.kind === 'calendar' && source.from && source.to) { await add( await this.sources.requestMany( userId, @@ -264,11 +272,11 @@ export class TbankSyncService { [uid], true, [ - type === 'share' - ? 'dividends' - : scope.screen === 'bond' - ? 'coupons' - : 'bond-events', + type === 'share' ? + 'dividends' : + scope.screen === 'bond' ? + 'coupons' : + 'bond-events', ], { from: source.from.toISOString().slice(0, 10), @@ -279,6 +287,7 @@ export class TbankSyncService { }, ), ); + } } if ( (scope.screen === 'stock' || scope.screen === 'bond') && @@ -292,14 +301,14 @@ export class TbankSyncService { uid: scope.instrumentUid, instrumentType: scope.screen === 'bond' ? 'bond' : 'share', period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, now, true, @@ -324,15 +333,16 @@ export class TbankSyncService { }); const saved = await this.repository.read(userId, connection.revision, id); if (!saved) throw new NotFoundException(); - if (!saved.work.scopeKey.startsWith('screen:')) + if (!saved.work.scopeKey.startsWith('screen:')) { return this.sourceStatus(userId, saved.work); + } const intent = saved.work.intent as unknown as ScreenIntent; await this.access.local(userId, intent.scope.accountId); const children = await Promise.all( saved.children.map((child) => - child.state === 'superseded' - ? this.operations.observe(userId, child.id) - : Promise.resolve(child), + child.state === 'superseded' ? + this.operations.observe(userId, child.id) : + Promise.resolve(child), ), ); const running = children.some((child) => child.state === 'running'); @@ -342,21 +352,22 @@ export class TbankSyncService { null; const state = saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) - ? 'cancelled' - : !saved.progress.dispatched || running - ? 'running' - : failed - ? 'failed' - : children.some((child) => child.state === 'cancelled') - ? 'cancelled' - : 'succeeded'; + (!saved.progress.dispatched || running) ? + 'cancelled' : + !saved.progress.dispatched || running ? + 'running' : + failed ? + 'failed' : + children.some((child) => child.state === 'cancelled') ? + 'cancelled' : + 'succeeded'; const resources = await this.observe(userId, intent.scope); if ( (await this.access.local(userId, intent.scope.accountId)).revision !== connection.revision - ) + ) { throw new NotFoundException(); + } return { id, ...intent, @@ -380,16 +391,16 @@ export class TbankSyncService { const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => { resources.push((await read).meta); }; - if (work.scopeKey === 'membership') + if (work.scopeKey === 'membership') { await add(this.accounts.list(userId, true)); - else if ( + } else if ( input.source === 'portfolio' && typeof input.accountId === 'string' - ) + ) { await add(this.portfolio.read(userId, input.accountId, true)); - else if (input.source === 'catalog') { + } else if (input.source === 'catalog') { const type = input.instrumentType === 'bond' ? 'bond' : 'share'; - if (typeof input.query === 'string') + if (typeof input.query === 'string') { await add( this.discovery.search( userId, @@ -401,14 +412,14 @@ export class TbankSyncService { true, ), ); - else if ( + } else if ( typeof input.uid === 'string' && typeof input.kind === 'string' ) { const range = - typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') - ? (JSON.parse(input.rangeKey) as string[]) - : null; + typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[') ? + (JSON.parse(input.rangeKey) as string[]) : + null; const sources = await this.sources.readMany( userId, type, @@ -417,9 +428,9 @@ export class TbankSyncService { [ input.kind as Parameters< CatalogEnrichmentService['readMany'] - >[4] extends (infer K)[] | undefined - ? K - : never, + >[4] extends (infer K)[] | undefined ? + K : + never, ], range ? { from: range[0], to: range[1] } : undefined, ); @@ -452,7 +463,7 @@ export class TbankSyncService { { uid: input.uid, instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share', - period: input.period as 'month' | 'halfYear' | 'year' | 'all', + period: input.period as 'all' | 'halfYear' | 'month' | 'year', }, true, ), @@ -475,21 +486,22 @@ export class TbankSyncService { ); } } - const observed = work.scopeKey.startsWith('operations:') - ? await this.operations.observe(userId, work.id) - : work; + const observed = work.scopeKey.startsWith('operations:') ? + await this.operations.observe(userId, work.id) : + work; const state = - observed.state === 'running' - ? observed.updatedAt < this.startedAt - ? 'cancelled' - : 'running' - : observed.state === 'failed' - ? 'failed' - : observed.state === 'cancelled' - ? 'cancelled' - : 'succeeded'; - if ((await this.access.local(userId)).revision !== work.revision) + observed.state === 'running' ? + observed.updatedAt < this.startedAt ? + 'cancelled' : + 'running' : + observed.state === 'failed' ? + 'failed' : + observed.state === 'cancelled' ? + 'cancelled' : + 'succeeded'; + if ((await this.access.local(userId)).revision !== work.revision) { throw new NotFoundException(); + } return { id: work.id, scope: null, @@ -511,14 +523,15 @@ export class TbankSyncService { resources.push((await read).meta); }; const account = scope.accountId ?? 'all'; - if (scope.screen === 'accounts') + if (scope.screen === 'accounts') { await add(this.accounts.list(userId, true)); + } if (scope.screen === 'account') { await add(this.portfolio.read(userId, account, true)); await add(this.income.summary(userId, account, true)); await add(this.events.get(userId, account, { sync: 'observe' })); } - if (['account', 'operations', 'stock', 'bond'].includes(scope.screen)) + if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) { await add( this.ledger.read(userId, account, { from: scope.from, @@ -529,11 +542,13 @@ export class TbankSyncService { sync: 'observe', }), ); - if (scope.screen === 'income') + } + if (scope.screen === 'income') { await add( this.income.analytics(userId, account, scope.from, scope.to, true), ); - if (scope.screen === 'events') + } + if (scope.screen === 'events') { await add( this.events.get(userId, account, { from: scope.from, @@ -541,9 +556,10 @@ export class TbankSyncService { sync: 'observe', }), ); + } const type = scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share'; - if (scope.screen === 'stocks' || scope.screen === 'bonds') + if (scope.screen === 'stocks' || scope.screen === 'bonds') { await add( this.discovery.search( userId, @@ -551,6 +567,7 @@ export class TbankSyncService { true, ), ); + } if ( (scope.screen === 'stock' || scope.screen === 'bond') && scope.instrumentUid @@ -574,27 +591,27 @@ export class TbankSyncService { uid, instrumentType: type, period: scope.candlePeriod ?? 'month', - ...(scope.candleRange - ? { + ...(scope.candleRange ? + { range: { from: new Date(scope.candleRange.from), to: new Date(scope.candleRange.to), }, - } - : {}), + } : + {}), }, true, ), ); await add( - type === 'share' - ? this.stockIncome.readPosition(userId, uid, true) - : this.bondIncome.readPosition(userId, uid, true), + type === 'share' ? + this.stockIncome.readPosition(userId, uid, true) : + this.bondIncome.readPosition(userId, uid, true), ); await add( - type === 'share' - ? this.stockIncome.dividends(userId, uid, true) - : this.bondIncome.coupons(userId, uid, true), + type === 'share' ? + this.stockIncome.dividends(userId, uid, true) : + this.bondIncome.coupons(userId, uid, true), ); } return resources; diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts index 2a5d4e4..17bc59a 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -1,11 +1,13 @@ +import type { Request } from 'express'; + import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; -import type { Request } from 'express'; + import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { - UpcomingEventsResourceDto, UpcomingEventsQueryDto, + UpcomingEventsResourceDto, } from './upcoming-events.dto'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 55a7d8b..8d8353b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,11 +1,12 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, Matches } from 'class-validator'; + +import { MoneyDto } from '../../../common/money.dto'; +import { AccountReferenceDto } from '../account-reference.dto'; import { InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; -import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsDateString, IsOptional, Matches } from 'class-validator'; -import { MoneyDto } from '../../../common/money.dto'; -import { AccountReferenceDto } from '../account-reference.dto'; export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { @ApiPropertyOptional({ @@ -43,10 +44,10 @@ export class UpcomingEventDto { declare dateKind: 'event' | 'registry'; @ApiProperty({ type: String, nullable: true }) - declare figi: string | null; + declare figi: null | string; @ApiProperty({ type: String, nullable: true }) - declare instrumentUid: string | null; + declare instrumentUid: null | string; @ApiProperty() declare ticker: string; @@ -55,10 +56,10 @@ export class UpcomingEventDto { declare displayName: string; @ApiProperty({ type: String, nullable: true }) - declare isin: string | null; + declare isin: null | string; @ApiProperty({ type: String, nullable: true }) - declare logoName: string | null; + declare logoName: null | string; @ApiProperty({ type: MoneyDto, nullable: true }) declare projectedAmount: MoneyDto | null; @@ -80,7 +81,8 @@ export class UpcomingEventsDto { export class UpcomingEventsResourceDto { @ApiProperty({ type: UpcomingEventsDto, nullable: true }) - declare data: UpcomingEventsDto | null; + declare data: null | UpcomingEventsDto; + @ApiProperty({ type: InvestmentResourceMetaDto }) declare meta: InvestmentResourceMetaDto; } diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index fbd2ad2..ed66431 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -1,8 +1,9 @@ -import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; -import { PortfolioModule } from '../portfolio/portfolio.module'; import { Module } from '@nestjs/common'; + import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { AuthModule } from '../../auth/auth.module'; +import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; +import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index 762017f..207e233 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,5 +1,5 @@ -import { dividendEvent, bondEvent } from './upcoming-events.service'; import { decimalFromString } from '../../../common/decimal'; +import { bondEvent, dividendEvent } from './upcoming-events.service'; jest.mock('../../../prisma/prisma.service', () => ({ PrismaService: class {}, })); diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index b1f2c36..2f6534c 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -1,5 +1,7 @@ import { BadRequestException, Injectable } from '@nestjs/common'; -import { MILLISECONDS_PER_SECOND } from '../../../common/time'; + +import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; + import { add, decimalFromString, @@ -8,21 +10,21 @@ import { multiply, } from '../../../common/decimal'; import { MoneyDto } from '../../../common/money.dto'; -import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; +import { MILLISECONDS_PER_SECOND } from '../../../common/time'; import { decimal } from '../../../integrations/tbank/decimal'; import { - TbankAccessService, InstrumentMetadataService, metadataKey, + TbankAccessService, type TbankDividend, } from '../../../integrations/tbank/tbank.api'; -import { - PortfolioService, - ForecastService, - type PortfolioPositionDto, -} from '../portfolio/portfolio.api'; import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { investmentResourceMeta } from '../investment-resource.mapper'; +import { + ForecastService, + type PortfolioPositionDto, + PortfolioService, +} from '../portfolio/portfolio.api'; import { UpcomingEventDto, UpcomingEventsDto, @@ -74,7 +76,7 @@ export class UpcomingEventsService { metadata.get(metadataKey(position))?.data?.ticker || '', })) - .filter((entry): entry is { uid: string; ticker: string } => + .filter((entry): entry is { ticker: string; uid: string } => Boolean(entry.uid), ); const uids = [...new Set(identities.map((entry) => entry.uid))]; @@ -82,7 +84,7 @@ export class UpcomingEventsService { let offset = 0; offset < uids.length; offset += calendarReadBatchSize - ) + ) { sources.push( ...(await this.sources.readMany( userId, @@ -98,6 +100,7 @@ export class UpcomingEventsService { }, )), ); + } } for (const position of positions) { const generic = metadata.get(metadataKey(position))?.data; @@ -126,7 +129,7 @@ export class UpcomingEventsService { (source) => source.input.uid === uid && source.input.kind === - (type === 'share' ? 'dividends' : 'bond-events'), + (type === 'share' ? 'dividends' : 'bond-events'), ); const instrument = sources.find( @@ -148,15 +151,15 @@ export class UpcomingEventsService { }; const quantity = decimalFromString(position.quantity); const projected = - type === 'share' - ? (calendar.payload?.dividends ?? []) + type === 'share' ? + (calendar.payload?.dividends ?? []) .filter( (event) => event.dividendType?.toLowerCase() !== 'cancelled' && Boolean(event.recordDate), ) - .map((event) => dividendEvent(identity, quantity, event)) - : (calendar.payload?.bondEvents ?? []).map((event) => + .map((event) => dividendEvent(identity, quantity, event)) : + (calendar.payload?.bondEvents ?? []).map((event) => bondEvent(identity, quantity, event), ); events.push( @@ -166,9 +169,9 @@ export class UpcomingEventsService { ) .map((event) => ({ ...event, - ...(accountId === 'all' && position.account - ? { account: position.account } - : {}), + ...(accountId === 'all' && position.account ? + { account: position.account } : + {}), })), ); } @@ -179,14 +182,14 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: UpcomingEventsDto | undefined = ready - ? { + const candidate: undefined | UpcomingEventsDto = ready ? + { accountId, calculatedAt: new Date().toISOString(), events, incomeTotalsByCurrency: incomeTotals(events), - } - : undefined; + } : + undefined; return this.forecasts.result({ userId, revision: connection.revision, @@ -197,6 +200,7 @@ export class UpcomingEventsService { candidate, }); } + getAll(userId: string, query: UpcomingEventsQueryDto = {}) { return this.get(userId, 'all', query); } @@ -205,7 +209,7 @@ export class UpcomingEventsService { export function dividendEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: TbankDividend, @@ -217,20 +221,20 @@ export function dividendEvent( .toISOString() .slice(0, 10), dateKind: 'registry', - projectedAmount: event.dividendNet - ? projectedMoney( + projectedAmount: event.dividendNet ? + projectedMoney( quantity, formatDecimal(decimal(event.dividendNet)), event.dividendNet.currency, - ) - : null, + ) : + null, }; } export function bondEvent( metadata: Pick< UpcomingEventDto, - 'figi' | 'instrumentUid' | 'ticker' | 'displayName' | 'isin' | 'logoName' + 'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker' >, quantity: ReturnType, event: MoexBondEvent, @@ -241,16 +245,16 @@ export function bondEvent( date: event.date, dateKind: 'event', projectedAmount: - 'value' in event - ? projectedMoney(quantity, event.value, event.currency) - : null, + 'value' in event ? + projectedMoney(quantity, event.value, event.currency) : + null, }; } function supported(position: Position): boolean { return ( !isZero(decimalFromString(position.quantity)) && - ['STOCK', 'BOND'].includes(position.type) + ['BOND', 'STOCK'].includes(position.type) ); } @@ -259,8 +263,9 @@ function dateWindow(query: UpcomingEventsQueryDto) { const end = new Date(`${from}T00:00:00.000Z`); end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS); const to = query.to ?? end.toISOString().slice(0, 10); - if (from > to) + if (from > to) { throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' }); + } return { from, to }; } @@ -280,8 +285,9 @@ function projectedMoney( function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { const totals = new Map>(); for (const event of events) { - if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) + if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { continue; + } const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0, diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts index 7207426..d30ce68 100644 --- a/src/prisma/prisma.module.ts +++ b/src/prisma/prisma.module.ts @@ -1,4 +1,5 @@ import { Global, Module } from '@nestjs/common'; + import { PrismaService } from './prisma.service'; @Global() diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts index c65572a..6af476c 100644 --- a/src/prisma/prisma.service.ts +++ b/src/prisma/prisma.service.ts @@ -1,6 +1,7 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; + import { PrismaClient } from '../../generated/prisma/client'; import { Environment } from '../config/env.validation'; @@ -11,8 +12,7 @@ function getDatabaseUrl(config: ConfigService): string { @Injectable() export class PrismaService extends PrismaClient - implements OnModuleInit, OnModuleDestroy -{ + implements OnModuleDestroy, OnModuleInit { constructor(config: ConfigService) { super({ adapter: new PrismaBetterSqlite3({ diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index f6cef5b..9bb7cd0 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('All accounts HTTP boundary', () => { let app: INestApplication; @@ -78,15 +80,10 @@ describe('All accounts HTTP boundary', () => { .expect(200); expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('generation'); - if (path === 'portfolio') - expect(response.body).toMatchObject({ - data: { positions: [] }, - meta: { availability: 'complete' }, - }); - else - expect(response.body).toMatchObject({ - meta: { availability: 'complete' }, - }); + expect(response.body).toMatchObject({ + meta: { availability: 'complete' }, + ...(path === 'portfolio' ? { data: { positions: [] } } : {}), + }); }, ); diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index d1ed34f..f73ba25 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,13 +4,14 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PinoLogger } from 'nestjs-pino'; import request from 'supertest'; import { App } from 'supertest/types'; + import { AppModule } from '../src/app.module'; import { StructuredLogger } from '../src/common/logging/structured-logger.service'; import { configureApp } from '../src/main'; import { AuthService } from '../src/modules/auth/application/auth.service'; import { PrismaService } from '../src/prisma/prisma.service'; -type Authentication = { user: { email: string }; accessToken: string }; +type Authentication = { accessToken: string; user: { email: string } }; type Problem = { code: string }; describe('Authentication (e2e)', () => { @@ -181,7 +182,7 @@ describe('Authentication (e2e)', () => { ); barrier.mockRestore(); expect(arrived).toBe(2); - expect(responses.map((response) => response.status).sort()).toEqual([ + expect(responses.map((response) => response.status).sort((a, b) => a - b)).toEqual([ 200, 401, ]); const winner = responses.find((response) => response.status === 200); diff --git a/test/bank-cards-durability.cjs b/test/bank-cards-durability.cjs index 23e801a..7fb7f0b 100644 --- a/test/bank-cards-durability.cjs +++ b/test/bank-cards-durability.cjs @@ -1,22 +1,25 @@ const assert = require('node:assert/strict'); const { spawn, execFileSync } = require('node:child_process'); +const { randomUUID } = require('node:crypto'); +const { once } = require('node:events'); const { mkdtempSync, openSync, closeSync, writeFileSync, existsSync, readdirSync, readFileSync } = require('node:fs'); +const { createServer } = require('node:net'); const { tmpdir } = require('node:os'); const { join, resolve } = require('node:path'); -const { once } = require('node:events'); -const { createServer } = require('node:net'); -const { randomUUID } = require('node:crypto'); const repo = resolve(__dirname, '..'); assert.ok(process.argv[2], 'Pass a separately built baseline repository directory'); const baseline = resolve(process.argv[2]); assert.notEqual(baseline, repo, 'Baseline must be a separate historical build'); for (const root of [baseline, repo]) { - assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + assert.ok(existsSync(join(root, 'dist/src/main.js')), `Build first: ${root}`); } -const migrations = root => readdirSync(join(root, 'prisma/migrations')).filter(name => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); -const previous = migrations(baseline), current = migrations(repo); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. +const migrations = (root) => readdirSync(join(root, 'prisma/migrations')).filter((name) => existsSync(join(root, 'prisma/migrations', name, 'migration.sql'))).sort(); +const current = migrations(repo), previous = migrations(baseline); assert.ok(previous.length > 0 && previous.length < current.length, 'Baseline must precede the tested migrations'); assert.deepEqual(previous, current.slice(0, previous.length), 'Baseline migrations must be an unchanged prefix'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. for (const name of previous) assert.equal(readFileSync(join(baseline, 'prisma/migrations', name, 'migration.sql'), 'utf8'), readFileSync(join(repo, 'prisma/migrations', name, 'migration.sql'), 'utf8')); const Database = require(join(repo, 'node_modules/better-sqlite3')); const dir = mkdtempSync(join(tmpdir(), 'wealthledger-bank-durability-')); @@ -26,97 +29,184 @@ const env = { ...process.env, DATABASE_URL: `file:${file}`, NODE_ENV: 'developme delete env.RUST_LOG; let child, origin, run = 0; const credentials = { email: 'durability@example.test', password: 'fictional password long enough' }; -const rub = value => ({ currency: 'RUB', value }); +const rub = (value) => ({ currency: 'RUB', value }); async function start(root) { - const server = createServer(); server.listen(0, '127.0.0.1'); await once(server,'listening'); const port=server.address().port; await new Promise(r=>server.close(r)); - origin=`http://127.0.0.1:${port}`; - const log = openSync(join(dir,`app-${++run}.log`),'w'); - child=spawn(process.execPath,[join(root,'dist/src/main.js')],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`,PORT:String(port)},stdio:['ignore',log,log]}); closeSync(log); - for(let i=0;i<150;i++){if(child.exitCode!==null)throw new Error(`app exited ${child.exitCode}`);try{const r=await fetch(`${origin}/api/v1/auth/me`);if(r.status===401)return;}catch{} await new Promise(r=>setTimeout(r,100));} - throw new Error('app startup timeout'); + const server = createServer(); + server.listen(0, '127.0.0.1'); + await once(server, 'listening'); + const port = server.address().port; + await new Promise((r) => server.close(r)); + origin = `http://127.0.0.1:${port}`; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + const log = openSync(join(dir, `app-${++run}.log`), 'w'); + child = spawn(process.execPath, [join(root, 'dist/src/main.js')], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}`, PORT: String(port) }, stdio: ['ignore', log, log] }); + closeSync(log); + for (let i = 0; i < 150; i++) { + if (child.exitCode !== null) throw new Error(`app exited ${child.exitCode}`); + try { + const r = await fetch(`${origin}/api/v1/auth/me`); + if (r.status === 401) return; + } catch { /* Startup connection failures are retried until the deadline. */ } + await new Promise((r) => setTimeout(r, 100)); + } + throw new Error('app startup timeout'); +} +async function stop() { + if (!child) return; + const c = child; + child = null; + if (c.exitCode !== null) return; + const ended = once(c, 'exit'); + c.kill('SIGTERM'); + await ended; +} +async function http(path, method = 'GET', body, token, status = 200) { + const r = await fetch(`${origin}/api/v1/${path}`, { method, headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }); + const text = await r.text(); + assert.equal(r.status, status, `${method} ${path}: ${text}`); + return text ? JSON.parse(text) : null; +} + +function migrate(root) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. + execFileSync(process.execPath, [join(root, 'node_modules/prisma/build/index.js'), 'migrate', 'deploy'], { cwd: root, env: { ...env, DATABASE_URL: `file:${file}` }, stdio: ['ignore', openSync(join(dir, `migration-${run}.log`), 'w'), 'pipe'] }); +} +function rows(table) { + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all(); + } finally { + db.close(); + } } -async function stop(){ if(!child)return;const c=child; child=null; if(c.exitCode!==null)return; const ended=once(c,'exit');c.kill('SIGTERM');await ended;} -async function http(path,method='GET',body,token,status=200){const r=await fetch(`${origin}/api/v1/${path}`,{method,headers:{...(body?{'content-type':'application/json'}:{}),...(token?{Authorization:`Bearer ${token}`}:{})},...(body?{body:JSON.stringify(body)}:{})});const text=await r.text();assert.equal(r.status,status,`${method} ${path}: ${text}`);return text?JSON.parse(text):null;} -function migrate(root){execFileSync(process.execPath,[join(root,'node_modules/prisma/build/index.js'),'migrate','deploy'],{cwd:root,env:{...env,DATABASE_URL:`file:${file}`},stdio:['ignore',openSync(join(dir,`migration-${run}.log`),'w'),'pipe']});} -function rows(table){const db=new Database(file,{readonly:true});db.defaultSafeIntegers(true);try{return db.prepare(`SELECT * FROM "${table}" ORDER BY id`).all();}finally{db.close();}} // The rollback build must leave every additive T-Bank dataset and work intent intact. function seedTbank(owner) { - const db = new Database(file); - const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; - const now = Date.now(); - const scope = {userId:owner,revision,accountId:'durable-account'}; - const exact = {currency:'RUB',value:'-9007199254740993.123456789'}; - const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map(key=>`"${key}"`).join(',')}) VALUES (${Object.keys(row).map(()=>'?').join(',')})`).run(...Object.values(row)); - db.transaction(() => { - db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now,owner); - insert('TbankAccount',{...scope,type:'BROKERAGE',name:'Synthetic persisted account',upstreamOrder:0}); - insert('TbankPortfolio',{...scope,payload:JSON.stringify({positions:[],cashBalances:[],totalsByCurrency:[],structure:{currencies:[],unvaluedCategories:[]}}),fetchedAt:now}); - insert('TbankOperation',{...scope,brokerOperationId:'durable-income',occurredAt:now,category:'income',payload:JSON.stringify({id:'durable-income',payment:exact})}); - insert('TbankOperationCoverage',{...scope,from:now-1000,to:now+1000,fetchedAt:now,asOf:now}); - insert('TbankForecast',{userId:owner,revision,scopeKey:'durable-forecast',membershipRevision:1,payload:JSON.stringify({incomeTotalsByCurrency:[exact]}),dependencyRevisions:JSON.stringify({portfolio:1}),sourceTimestamps:JSON.stringify({portfolio:new Date(now).toISOString()}),calculatedAt:now}); - insert('TbankInstrument',{provider:'tbank',uid:'durable-instrument',instrumentType:'bond',metadata:JSON.stringify({name:'Synthetic'}),fetchedAt:now}); - insert('TbankCatalogDiscovery',{provider:'tbank',instrumentType:'bond',query:'SYN',instrumentUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentLookup',{provider:'tbank',identifierType:'ticker',identifier:'SYN',matchedUids:JSON.stringify(['durable-instrument']),fetchedAt:now}); - insert('TbankInstrumentSource',{provider:'tbank',instrumentUid:'durable-instrument',source:'tbank',kind:'coupons',rangeKey:'durable',payload:JSON.stringify({events:[]}),fetchedAt:now}); - insert('TbankCandle',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',time:now,close:exact.value,payload:JSON.stringify({close:exact.value}),isComplete:1,fetchedAt:now}); - insert('TbankCandleCoverage',{provider:'tbank',instrumentUid:'durable-instrument',interval:'day',from:now-1000,to:now+1000,isComplete:1,fetchedAt:now}); - insert('TbankSyncWork',{id:'durable-work',userId:owner,revision,scopeKey:'durable-intent',intent:JSON.stringify({from:now-1000,to:now+1000}),state:'running',attemptId:'stopped-attempt',processRunId:'stopped-process',priority:0,progress:JSON.stringify({completed:1}),createdAt:now,updatedAt:now}); - insert('TbankSyncStage',{workId:'durable-work',attemptId:'stopped-attempt',sourceKey:'durable',itemKey:'next-row',payload:JSON.stringify({payment:exact})}); - })(); - db.close(); + const db = new Database(file); + const revision = db.prepare('SELECT revision FROM TbankConnection WHERE userId=?').get(owner).revision; + const now = Date.now(); + const scope = { userId: owner, revision, accountId: 'durable-account' }; + const exact = { currency: 'RUB', value: '-9007199254740993.123456789' }; + const insert = (table, row) => db.prepare(`INSERT INTO "${table}" (${Object.keys(row).map((key) => `"${key}"`).join(',')}) VALUES (${Object.keys(row).map(() => '?').join(',')})`).run(...Object.values(row)); + db.transaction(() => { + db.prepare('UPDATE TbankConnection SET membershipAt=?,membershipRevision=1 WHERE userId=?').run(now, owner); + insert('TbankAccount', { ...scope, type: 'BROKERAGE', name: 'Synthetic persisted account', upstreamOrder: 0 }); + insert('TbankPortfolio', { ...scope, payload: JSON.stringify({ positions: [], cashBalances: [], totalsByCurrency: [], structure: { currencies: [], unvaluedCategories: [] } }), fetchedAt: now }); + insert('TbankOperation', { ...scope, brokerOperationId: 'durable-income', occurredAt: now, category: 'income', payload: JSON.stringify({ id: 'durable-income', payment: exact }) }); + insert('TbankOperationCoverage', { ...scope, from: now - 1000, to: now + 1000, fetchedAt: now, asOf: now }); + insert('TbankForecast', { userId: owner, revision, scopeKey: 'durable-forecast', membershipRevision: 1, payload: JSON.stringify({ incomeTotalsByCurrency: [exact] }), dependencyRevisions: JSON.stringify({ portfolio: 1 }), sourceTimestamps: JSON.stringify({ portfolio: new Date(now).toISOString() }), calculatedAt: now }); + insert('TbankInstrument', { provider: 'tbank', uid: 'durable-instrument', instrumentType: 'bond', metadata: JSON.stringify({ name: 'Synthetic' }), fetchedAt: now }); + insert('TbankCatalogDiscovery', { provider: 'tbank', instrumentType: 'bond', query: 'SYN', instrumentUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentLookup', { provider: 'tbank', identifierType: 'ticker', identifier: 'SYN', matchedUids: JSON.stringify(['durable-instrument']), fetchedAt: now }); + insert('TbankInstrumentSource', { provider: 'tbank', instrumentUid: 'durable-instrument', source: 'tbank', kind: 'coupons', rangeKey: 'durable', payload: JSON.stringify({ events: [] }), fetchedAt: now }); + insert('TbankCandle', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', time: now, close: exact.value, payload: JSON.stringify({ close: exact.value }), isComplete: 1, fetchedAt: now }); + insert('TbankCandleCoverage', { provider: 'tbank', instrumentUid: 'durable-instrument', interval: 'day', from: now - 1000, to: now + 1000, isComplete: 1, fetchedAt: now }); + insert('TbankSyncWork', { id: 'durable-work', userId: owner, revision, scopeKey: 'durable-intent', intent: JSON.stringify({ from: now - 1000, to: now + 1000 }), state: 'running', attemptId: 'stopped-attempt', processRunId: 'stopped-process', priority: 0, progress: JSON.stringify({ completed: 1 }), createdAt: now, updatedAt: now }); + insert('TbankSyncStage', { workId: 'durable-work', attemptId: 'stopped-attempt', sourceKey: 'durable', itemKey: 'next-row', payload: JSON.stringify({ payment: exact }) }); + })(); + db.close(); } function tbankSnapshot() { - const db = new Database(file,{readonly:true}); - db.defaultSafeIntegers(true); - try { - assert.equal(db.pragma('integrity_check',{simple:true}),'ok'); - assert.deepEqual(db.pragma('foreign_key_check'),[]); - return Object.fromEntries(db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND (name LIKE 'Tbank%' OR name='_prisma_migrations') ORDER BY name").all().map(({name})=>[name,db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); - } finally {db.close();} + const db = new Database(file, { readonly: true }); + db.defaultSafeIntegers(true); + try { + assert.equal(db.pragma('integrity_check', { simple: true }), 'ok'); + assert.deepEqual(db.pragma('foreign_key_check'), []); + return Object.fromEntries(db.prepare('SELECT name FROM sqlite_master WHERE type=\'table\' AND (name LIKE \'Tbank%\' OR name=\'_prisma_migrations\') ORDER BY name').all().map(({ name }) => [name, db.prepare(`SELECT * FROM "${name}" ORDER BY rowid`).all()])); + } finally { + db.close(); + } } -(async()=>{ - try{ - migrate(baseline); await start(baseline); - const auth=await http('auth/register','POST',credentials); const owner=auth.user.id; - await stop(); - const db=new Database(file);db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner,'fictional-encrypted-token-never-used',new Date().toISOString());await db.backup(join(dir,'before-migration.db'));db.close(); - const beforeUsers=rows('User'), beforeSessions=rows('Session'); - migrate(repo); - assert.deepEqual(rows('User'),beforeUsers); assert.deepEqual(rows('Session'),beforeSessions); const migratedDb=new Database(file,{readonly:true});assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');migratedDb.close(); - await start(repo); let token=(await http('auth/login','POST',credentials)).accessToken; - const intent={id:randomUUID(),name:'Долговременная',openingDate:'2026-01-01',openingBalance:rub('1000')}; - let card=await http('bank-cards','POST',intent,token,201); - const category=(await http('bank-cards/categories','GET',null,token)).find(x=>x.isDefault); - await http(`bank-cards/categories/${category.id}`,'PATCH',{name:'Сохранённая категория',version:category.version},token); - const expenseIntent={id:randomUUID(),kind:'expense',cardId:card.id,amount:rub('100'),date:'2026-08-01'}; - let expense=await http('bank-cards/operations','POST',expenseIntent,token,201); - const refundIntent={id:randomUUID(),kind:'refund',cardId:card.id,originalExpenseId:expense.id,amount:rub('100'),date:'2026-09-01'}; - const refund=await http('bank-cards/operations','POST',refundIntent,token,201); - const deletedIntent={id:randomUUID(),kind:'topup',cardId:card.id,amount:rub('12.34'),date:'2026-09-01'}; - const deleted=await http('bank-cards/operations','POST',deletedIntent,token,201); - await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`,'DELETE',null,token,204); - card=await http(`bank-cards/${card.id}`,'GET',null,token); - card=await http(`bank-cards/${card.id}`,'PATCH',{version:card.version,archived:true},token); - seedTbank(owner); const tbankBefore=tbankSnapshot(); - const snapshots={cards:rows('BankCard'),operations:rows('BankCardOperation'),categories:rows('ExpenseCategory')}; - await stop(); await start(repo);token=(await http('auth/login','POST',credentials)).accessToken; - assert.deepEqual(await http('bank-cards','POST',intent,token,201),card); - assert.deepEqual(await http('bank-cards/operations','POST',refundIntent,token,201),refund); - await http('bank-cards/operations','POST',deletedIntent,token,410); - assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card); - await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - const latest=new Database(file);await latest.backup(join(dir,'after-history.db'));latest.close(); - await start(baseline);token=(await http('auth/login','POST',credentials)).accessToken;assert.equal((await http('auth/me','GET',null,token)).id,owner);await stop(); - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - const restored=join(dir,'restored.db');const backup=new Database(join(dir,'after-history.db'),{readonly:true});await backup.backup(restored);backup.close();file=restored; - assert.deepEqual(tbankSnapshot(),tbankBefore);assert.deepEqual(rows('BankCard'),snapshots.cards);assert.deepEqual(rows('BankCardOperation'),snapshots.operations);assert.deepEqual(rows('ExpenseCategory'),snapshots.categories); - assert.deepEqual(rows('User'),beforeUsers);assert.ok(rows('Session').some(s=>s.id===beforeSessions[0].id)); - assert.deepEqual(tbankSnapshot(),tbankBefore); - const restoredDb=new Database(file,{readonly:true});assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken,'fictional-encrypted-token-never-used');assert.equal(restoredDb.pragma('integrity_check',{simple:true}),'ok');restoredDb.close(); - await start(repo);token=(await http('auth/login','POST',credentials)).accessToken;assert.deepEqual(await http(`bank-cards/${card.id}`,'GET',null,token),card);await stop(); - writeFileSync(join(dir,'result.json'),JSON.stringify({result:'PASS',baseline,dir,checks:['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore','foreign key and integrity checks after each boundary','baseline migration and auth','additive migration preserves User Session TbankConnection','restart preserves archive categories refund links tombstones fingerprints','same-intent replay after restart','previous application auth with additive schema','new application returns preserved history after rollback','SQLite backup and restored application reads history']},null,2)); - console.log(`PASS durability ${dir}`); - }finally{await stop();} -})().catch(error=>{console.error(error);process.exitCode=1;}); +(async () => { + try { + migrate(baseline); + await start(baseline); + const auth = await http('auth/register', 'POST', credentials); + const owner = auth.user.id; + await stop(); + const db = new Database(file); + db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString()); + await db.backup(join(dir, 'before-migration.db')); + db.close(); + const beforeSessions = rows('Session'), beforeUsers = rows('User'); + migrate(repo); + assert.deepEqual(rows('User'), beforeUsers); + assert.deepEqual(rows('Session'), beforeSessions); + const migratedDb = new Database(file, { readonly: true }); + assert.equal(migratedDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + migratedDb.close(); + await start(repo); + let token = (await http('auth/login', 'POST', credentials)).accessToken; + const intent = { id: randomUUID(), name: 'Долговременная', openingDate: '2026-01-01', openingBalance: rub('1000') }; + let card = await http('bank-cards', 'POST', intent, token, 201); + const category = (await http('bank-cards/categories', 'GET', null, token)).find((x) => x.isDefault); + await http(`bank-cards/categories/${category.id}`, 'PATCH', { name: 'Сохранённая категория', version: category.version }, token); + const expenseIntent = { id: randomUUID(), kind: 'expense', cardId: card.id, amount: rub('100'), date: '2026-08-01' }; + let expense = await http('bank-cards/operations', 'POST', expenseIntent, token, 201); + const refundIntent = { id: randomUUID(), kind: 'refund', cardId: card.id, originalExpenseId: expense.id, amount: rub('100'), date: '2026-09-01' }; + const refund = await http('bank-cards/operations', 'POST', refundIntent, token, 201); + const deletedIntent = { id: randomUUID(), kind: 'topup', cardId: card.id, amount: rub('12.34'), date: '2026-09-01' }; + const deleted = await http('bank-cards/operations', 'POST', deletedIntent, token, 201); + await http(`bank-cards/operations/${deleted.id}?version=${deleted.version}`, 'DELETE', null, token, 204); + card = await http(`bank-cards/${card.id}`, 'GET', null, token); + card = await http(`bank-cards/${card.id}`, 'PATCH', { version: card.version, archived: true }, token); + seedTbank(owner); + const tbankBefore = tbankSnapshot(); + const snapshots = { cards: rows('BankCard'), operations: rows('BankCardOperation'), categories: rows('ExpenseCategory') }; + await stop(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http('bank-cards', 'POST', intent, token, 201), card); + assert.deepEqual(await http('bank-cards/operations', 'POST', refundIntent, token, 201), refund); + await http('bank-cards/operations', 'POST', deletedIntent, token, 410); + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + const latest = new Database(file); + await latest.backup(join(dir, 'after-history.db')); + latest.close(); + await start(baseline); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.equal((await http('auth/me', 'GET', null, token)).id, owner); + await stop(); + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + const restored = join(dir, 'restored.db'); + const backup = new Database(join(dir, 'after-history.db'), { readonly: true }); + await backup.backup(restored); + backup.close(); + file = restored; + assert.deepEqual(tbankSnapshot(), tbankBefore); + assert.deepEqual(rows('BankCard'), snapshots.cards); + assert.deepEqual(rows('BankCardOperation'), snapshots.operations); + assert.deepEqual(rows('ExpenseCategory'), snapshots.categories); + assert.deepEqual(rows('User'), beforeUsers); + assert.ok(rows('Session').some((s) => s.id === beforeSessions[0].id)); + assert.deepEqual(tbankSnapshot(), tbankBefore); + const restoredDb = new Database(file, { readonly: true }); + assert.equal(restoredDb.prepare('SELECT encryptedToken FROM TbankConnection WHERE userId=?').get(owner).encryptedToken, 'fictional-encrypted-token-never-used'); + assert.equal(restoredDb.pragma('integrity_check', { simple: true }), 'ok'); + restoredDb.close(); + await start(repo); + token = (await http('auth/login', 'POST', credentials)).accessToken; + assert.deepEqual(await http(`bank-cards/${card.id}`, 'GET', null, token), card); + await stop(); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. + writeFileSync(join(dir, 'result.json'), JSON.stringify({ result: 'PASS', baseline, dir, checks: ['all populated T-Bank datasets, staging, progress and migration state survive restart, rollback and isolated restore', 'foreign key and integrity checks after each boundary', 'baseline migration and auth', 'additive migration preserves User Session TbankConnection', 'restart preserves archive categories refund links tombstones fingerprints', 'same-intent replay after restart', 'previous application auth with additive schema', 'new application returns preserved history after rollback', 'SQLite backup and restored application reads history'] }, null, 2)); + console.log(`PASS durability ${dir}`); + } finally { + await stop(); + } +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index 1585a47..cfa6de5 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -1,10 +1,12 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import assert from 'node:assert/strict'; import { randomUUID } from 'node:crypto'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { @@ -28,12 +30,12 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ); const money = (value: string) => ({ currency: 'RUB', value }); const auth = ( - method: 'get' | 'post' | 'patch' | 'delete', + method: 'delete' | 'get' | 'patch' | 'post', path: string, user = owner, ) => - request(app.getHttpServer()) - [method]('/api/v1/bank-cards' + path) + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. + request(app.getHttpServer())[method]('/api/v1/bank-cards' + path) .set('Authorization', `Bearer ${token(user)}`); const createCard = async (value = '1000', user = owner, extra = {}) => ( @@ -102,10 +104,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { if (app) await app.close(); }); it('requires auth and reads never seed data or need T-Bank', async () => { - for (const path of ['', '/categories', '/summary?month=2026-08']) + for (const path of ['', '/categories', '/summary?month=2026-08']) { await request(app.getHttpServer()) .get('/api/v1/bank-cards' + path) .expect(401); + } expect((await auth('get', '').expect(200)).body).toEqual([]); expect(await categories()).toEqual([]); expect(await db.expenseCategory.count({ where: { userId: owner } })).toBe( @@ -241,10 +244,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { money('92233720368547758.08'), { currency: 'USD', value: '1' }, money('NaN'), - ]) + ]) { await auth('post', '/operations') .send({ ...op(card.id), amount }) .expect(400); + } for (const extra of [ { date: '2026-02-30' }, { date: '2099-01-01' }, @@ -252,10 +256,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { description: 'x'.repeat(501) }, { kind: 'topup', categoryId: (await categories())[0].id }, { kind: 'transfer', targetCardId: card.id }, - ]) + ]) { await auth('post', '/operations') .send(op(card.id, 'expense', '1', extra)) .expect(400); + } expect(await db.bankCardOperation.count()).toBe(before); for (const extra of [ { name: ' ' }, @@ -264,7 +269,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { lastFour: '123' }, { openingDate: '2026-02-30' }, { openingBalance: money('-92233720368547758.09') }, - ]) + ]) { await auth('post', '') .send({ id: randomUUID(), @@ -274,6 +279,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ...extra, }) .expect(400); + } }); it('returns nested money field errors without exposing input or writing a card', async () => { const before = await db.bankCard.count(); @@ -310,8 +316,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); it('scopes every card/category/expense link and edit to owner', async () => { - const own = await createCard(), - foreign = await createCard('0', stranger); + const foreign = await createCard('0', stranger), + own = await createCard(); const foreignCategories = ( await auth('get', '/categories', stranger).expect(200) ).body as ExpenseCategoryDto[]; @@ -326,15 +332,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { '/' + foreign.id + '/expenses', '/operations/' + foreignOp.id, '/summary?month=2026-08&cardId=' + foreign.id, - ]) + ]) { await auth('get', path).expect(404); + } for (const body of [ op(foreign.id), op(own.id, 'expense', '1', { categoryId: foreignCategories[0].id }), op(own.id, 'transfer', '1', { targetCardId: foreign.id }), op(own.id, 'refund', '1', { originalExpenseId: foreignOp.id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(404); + } await auth('patch', '/' + foreign.id) .send({ version: 1, archived: true }) .expect(404); @@ -457,7 +465,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }), ), ]); - expect(responses.map((r) => r.status).sort()).toEqual([201, 409]); + expect(responses.map((r) => r.status).sort((a, b) => a - b)).toEqual([201, 409]); const body = op(card.id, 'refund', '400', { date: '2026-09-01', originalExpenseId: expense.id, @@ -484,10 +492,11 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { { amount: money('999') }, { date: '2026-09-02' }, { cardId: (await createCard()).id }, - ]) + ]) { await auth('patch', '/operations/' + expense.id) .send({ version: current.version, ...patch }) .expect(409); + } await auth( 'delete', `/operations/${expense.id}?version=${current.version}`, @@ -501,6 +510,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { category.id, ); }); + // eslint-disable-next-line sonarjs/assertions-in-tests -- Every rejection is asserted through awaited Supertest .expect(400). it('rejects invalid refund references, dates, immutable kind and purchase link', async () => { const card = await createCard(), other = await createCard(); @@ -513,8 +523,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { originalExpenseId: purchase.id, date: '2026-07-01', }), - ]) + ]) { await auth('post', '/operations').send(body).expect(400); + } const refund = await createOp( op(card.id, 'refund', '10', { originalExpenseId: purchase.id }), ); @@ -532,7 +543,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { await createOp(op(a.id, 'expense', '20')); await createOp(op(a.id, 'expense', '30')); const pages: BankOperationsPageDto[] = []; - for (let page = 1; page <= 3; page++) + for (let page = 1; page <= 3; page++) { pages.push( ( await auth( @@ -541,6 +552,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { ).expect(200) ).body as BankOperationsPageDto, ); + } expect(new Set(pages.flatMap((p) => p.items.map((i) => i.id))).size).toBe( 3, ); @@ -564,8 +576,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { for (const body of [ op(card.id), op(card.id, 'transfer', '1', { targetCardId: (await createCard()).id }), - ]) + ]) { await auth('post', '/operations').send(body).expect(409); + } await auth('patch', '/operations/' + expense.id) .send({ version: 1, description: 'no' }) .expect(409); @@ -625,7 +638,7 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { }); expect( refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) <= - source.amountKopecks, + source.amountKopecks, ).toBe(true); }); it('serializes opening-date changes and validates archived previous transfer sides', async () => { @@ -755,8 +768,9 @@ describe('Manual bank cards configured HTTP and real SQLite', () => { it('filters journal and summary inclusively across months and rejects invalid periods', async () => { const card = await createCard(); const dates = ['2026-07-30', '2026-07-31', '2026-08-01', '2026-08-02']; - for (const date of dates) + for (const date of dates) { await createOp(op(card.id, 'expense', '10', { date })); + } const range = 'from=2026-07-31&to=2026-08-01'; const history = ( await auth('get', `/${card.id}/operations?${range}&limit=1`).expect(200) diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index c8b5d0d..8e53c35 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; +import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; -import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Bond HTTP contract', () => { diff --git a/test/container-smoke.cjs b/test/container-smoke.cjs index 6d0cb11..93efa14 100644 --- a/test/container-smoke.cjs +++ b/test/container-smoke.cjs @@ -6,6 +6,7 @@ const { randomUUID } = require('node:crypto'); const image = process.argv[2]; assert.ok(image, 'Pass a built backend image reference'); const name = `wealthledger-smoke-${randomUUID()}`; +// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. const docker = (...args) => execFileSync('docker', args, { encoding: 'utf8' }).trim(); const environment = [ '-e', 'DATABASE_URL=file:/data/wealthledger.db', @@ -64,6 +65,13 @@ async function ready() { `); console.log('PASS image: fresh/existing migration, non-root read-only runtime, auth, persistence, proto assets'); } finally { - try { docker('rm', '-f', name); } finally { docker('volume', 'rm', name); } + try { + docker('rm', '-f', name); + } finally { + docker('volume', 'rm', name); + } } -})().catch((error) => { console.error(error); process.exitCode = 1; }); +})().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/test/e2e-env.cjs b/test/e2e-env.cjs index 1c76d9d..d55c3dc 100644 --- a/test/e2e-env.cjs +++ b/test/e2e-env.cjs @@ -1,5 +1,5 @@ -const { join, dirname, basename } = require('node:path'); const { tmpdir } = require('node:os'); +const { join, dirname, basename } = require('node:path'); const dir = process.env.WL_E2E_DIRECTORY; if ( !dir || diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs new file mode 100644 index 0000000..48446da --- /dev/null +++ b/test/eslint-config.cjs @@ -0,0 +1,47 @@ +const { ESLint } = require('eslint'); +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const eslint = new ESLint(); + +// Protect the quality gate's file scopes when configs or plugin versions change. +test('typed application code enforces async and unsafe-type checks', async () => { + const [result] = await eslint.lintText( + 'export const unsafe: any = 1; Promise.resolve(unsafe);', + { filePath: 'src/main.ts' }, + ); + const rules = new Set(result.messages.map(({ ruleId }) => ruleId)); + assert.ok(rules.has('@typescript-eslint/no-explicit-any')); + assert.ok(rules.has('@typescript-eslint/no-floating-promises')); +}); + +test('framework and runtime rules reach their intended files', async () => { + const app = await eslint.calculateConfigForFile('src/main.ts'); + const spec = await eslint.calculateConfigForFile('test/app.e2e-spec.ts'); + const script = await eslint.calculateConfigForFile('scripts/generate-openapi.cjs'); + const config = await eslint.calculateConfigForFile('eslint.config.mjs'); + assert.equal(app.rules['@darraghor/nestjs-typed/validation-pipe-should-use-forbid-unknown'][0], 2); + assert.equal(spec.rules['jest/no-focused-tests'][0], 2); + assert.equal(app.rules['jest/no-focused-tests'], undefined); + assert.equal(app.languageOptions.globals.expect, undefined); + assert.equal(config.languageOptions.globals.require, 'off'); + for (const profile of [app, spec, script, config]) { + assert.equal(profile.rules['n/no-deprecated-api'][0], 2); + assert.equal(profile.rules['security/detect-eval-with-expression'][0], 1); + assert.equal(profile.rules['regexp/no-super-linear-backtracking'][0], 2); + assert.equal(profile.rules['sonarjs/cognitive-complexity'][0], 2); + assert.equal(profile.rules['import-x/no-unresolved'][0], 2); + assert.equal(profile.rules['perfectionist/sort-imports'][0], 2); + assert.equal(profile.rules['@stylistic/semi'][0], 2); + } +}); + +test('focused tests are rejected and generated code is excluded', async () => { + const [result] = await eslint.lintText( + 'it.only(\'example\', () => { expect(1).toBe(1); });', + { filePath: 'test/app.e2e-spec.ts' }, + ); + assert.ok(result.messages.some(({ ruleId }) => ruleId === 'jest/no-focused-tests')); + assert.ok(await eslint.isPathIgnored('generated/prisma/client.ts')); + assert.ok(await eslint.isPathIgnored('dist/src/main.js')); +}); diff --git a/test/fixtures/tbank-sync-crash.cjs b/test/fixtures/tbank-sync-crash.cjs index 8e13110..463615f 100644 --- a/test/fixtures/tbank-sync-crash.cjs +++ b/test/fixtures/tbank-sync-crash.cjs @@ -1,19 +1,20 @@ // This process is deliberately killed by the SQLite suite at durable boundaries. require('../e2e-env.cjs'); const { ConfigService } = require('@nestjs/config'); -const { PrismaService } = require('../../dist/src/prisma/prisma.service'); -const { - TbankTokenService, -} = require('../../dist/src/integrations/tbank/tbank-token.service'); + const { TbankAccessService, } = require('../../dist/src/integrations/tbank/tbank-access.service'); const { - BrokerageOperationsRepository, -} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); + TbankTokenService, +} = require('../../dist/src/integrations/tbank/tbank-token.service'); const { BrokerageOperationsSyncService, } = require('../../dist/src/modules/investments/operations/application/brokerage-operations-sync.service'); +const { + BrokerageOperationsRepository, +} = require('../../dist/src/modules/investments/operations/infrastructure/brokerage-operations.repository'); +const { PrismaService } = require('../../dist/src/prisma/prisma.service'); const mode = process.argv[2]; const prisma = new PrismaService(new ConfigService(process.env)); const repository = new BrokerageOperationsRepository(prisma); diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 2df4936..6034b08 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -5,6 +5,7 @@ import { execFileSync } from 'node:child_process'; import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { resolve } from 'node:path'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; import { createOpenApiDocument } from '../src/openapi'; @@ -56,6 +57,7 @@ describe('OpenAPI contract (integration)', () => { ['IncomeInvestmentScreenDto', 'income'], ['EventsInvestmentScreenDto', 'events'], ]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. const schema = schemas?.[model]; assert(schema && !('$ref' in schema)); expect(schema.required).toContain('screen'); @@ -84,7 +86,7 @@ describe('OpenAPI contract (integration)', () => { const parameters = document.paths[`/api/v1/brokerage-accounts/${scope}/${resource}`].get ?.parameters; - for (const name of ['from', 'to']) + for (const name of ['from', 'to']) { expect( parameters?.find( (parameter) => 'name' in parameter && parameter.name === name, @@ -94,6 +96,7 @@ describe('OpenAPI contract (integration)', () => { in: 'query', schema: { format }, }); + } } } }); @@ -147,6 +150,7 @@ describe('OpenAPI contract (integration)', () => { const directory = await mkdtemp(resolve(tmpdir(), 'wealthledger-openapi-')); try { const networkGuard = resolve(directory, 'no-network.cjs'); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await writeFile( networkGuard, ` @@ -170,6 +174,7 @@ describe('OpenAPI contract (integration)', () => { }, ); const generated = JSON.parse( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. await readFile(resolve(directory, 'openapi.json'), 'utf8'), ) as unknown; expect(generated).toEqual(createOpenApiDocument(app)); diff --git a/test/portfolio-structure.e2e-spec.ts b/test/portfolio-structure.e2e-spec.ts index c95bd8f..68a1c16 100644 --- a/test/portfolio-structure.e2e-spec.ts +++ b/test/portfolio-structure.e2e-spec.ts @@ -1,13 +1,15 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; -import request from 'supertest'; -import { AppModule } from '../src/app.module'; -import { PrismaService } from '../src/prisma/prisma.service'; import { setTimeout as delay } from 'node:timers/promises'; +import request from 'supertest'; + +import { AppModule } from '../src/app.module'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('Portfolio structure HTTP contract', () => { let app: INestApplication; @@ -72,8 +74,9 @@ describe('Portfolio structure HTTP contract', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } await delay(5); } throw new Error('Portfolio did not settle'); diff --git a/test/sqlite-regression.cjs b/test/sqlite-regression.cjs index fb7f43c..e6e0c96 100644 --- a/test/sqlite-regression.cjs +++ b/test/sqlite-regression.cjs @@ -12,7 +12,9 @@ const { join, resolve } = require('node:path'); const cwd = resolve(__dirname, '..'); const evidence = mkdtempSync(join(tmpdir(), 'wealthledger-sqlite-regression-')); const sentinel = join(evidence, 'user.db'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(sentinel, 'fictional user database: must never be opened'); +// eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. const before = readFileSync(sentinel); const databases = new Set(); async function check(name, overrides = {}, signal) { @@ -43,6 +45,7 @@ async function check(name, overrides = {}, signal) { child.once('error', reject); child.once('close', resolve); }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. writeFileSync(join(evidence, `${name}.log`), log); const dirs = [...log.matchAll(/^SQLite .+: (.+)$/gm)].map( (match) => match[1], @@ -51,21 +54,23 @@ async function check(name, overrides = {}, signal) { for (const dir of dirs) { assert.ok(!databases.has(dir), 'runners must never share databases'); databases.add(dir); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. assert.ok(!existsSync(dir), `runner must clean up ${dir}`); } assert.deepEqual( + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. readFileSync(sentinel), before, 'user database must be untouched', ); const expected = - signal === 'SIGINT' - ? 130 - : signal === 'SIGTERM' - ? 143 - : overrides.PRISMA_SCHEMA_ENGINE_BINARY - ? 1 - : 0; + signal === 'SIGINT' ? + 130 : + signal === 'SIGTERM' ? + 143 : + overrides.PRISMA_SCHEMA_ENGINE_BINARY ? + 1 : + 0; assert.equal(code, expected, `See ${name}.log`); console.log(`PASS ${name}: ${dirs.length} isolated databases`); } @@ -76,8 +81,9 @@ async function check(name, overrides = {}, signal) { check('parallel-1'), check('parallel-2', { RUST_LOG: 'error' }), ]); - for (const result of parallel) + for (const result of parallel) { if (result.status === 'rejected') throw result.reason; + } await check('migration-failure', { PRISMA_SCHEMA_ENGINE_BINARY: join(evidence, 'missing-engine'), }); diff --git a/test/sqlite.cjs b/test/sqlite.cjs index 74357e6..4bcedfc 100644 --- a/test/sqlite.cjs +++ b/test/sqlite.cjs @@ -9,13 +9,14 @@ const available = readdirSync(__dirname) .sort(); const requested = process.argv.slice(2); const suites = - requested.length === 0 - ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] - : requested.length === 1 && requested[0] === '--all' - ? available - : requested; -if (suites.some((suite) => !available.includes(suite))) + requested.length === 0 ? + ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] : + requested.length === 1 && requested[0] === '--all' ? + available : + requested; +if (suites.some((suite) => !available.includes(suite))) { throw new Error('Unknown e2e suite'); +} let child; let interrupted; let killTimer; @@ -46,9 +47,9 @@ function run(args, env) { child.once('close', (code, signal) => { clearTimeout(killTimer); child = undefined; - if (code !== 0 || signal) + if (code !== 0 || signal) { reject(new Error(`Child failed: ${signal ?? code}`)); - else resolve(); + } else resolve(); }); }); } diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index 6e04520..6bd6e54 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -1,3 +1,5 @@ +import type { Server } from 'node:http'; + import { ConflictException, INestApplication, @@ -6,13 +8,13 @@ import { } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; import { configureApp } from '../src/main'; +import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service'; import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service'; -import { StockCatalogService } from '../src/modules/investments/instruments/stocks/application/stock-catalog.service'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; describe('Stock HTTP contract', () => { diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index 6b5740a..cbdfd51 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -1,14 +1,15 @@ +import { ConfigService } from '@nestjs/config'; import { setTimeout as delay } from 'node:timers/promises'; -import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { CandleService } from '../src/modules/investments/instruments/application/candle.service'; import { candleGaps, type CandleRange, } from '../src/modules/investments/instruments/domain/candle-coverage'; +import { CandleRepository } from '../src/modules/investments/instruments/infrastructure/candle.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('atomic shared candle reconciliation (SQLite)', () => { const prisma = new PrismaService( @@ -56,8 +57,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); async function settled() { for (let i = 0; i < 500; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Candle work did not settle'); @@ -225,10 +227,11 @@ describe('atomic shared candle reconciliation (SQLite)', () => { string, Date, Date, - ][]) + ][]) { expect(call[3].valueOf() - call[2].valueOf()).toBeLessThanOrEqual( 365 * 86400000, ); + } await prisma.$disconnect(); const reopened = createService(); expect((await reopened.read('owner', input)).data?.candles).toEqual([]); @@ -353,13 +356,13 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ...sources, ensureDetail: jest.fn().mockResolvedValue(history), }; - if (phase === 'before loading') + if (phase === 'before loading') { selectedSources.ensureDetail.mockImplementationOnce(async () => { entered(); await blocked; return history; }); - else { + } else { const publish = repository.publish.bind(repository); jest .spyOn(repository, 'publish') @@ -377,8 +380,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { Awaited> | undefined; upstream.getCandles.mockImplementation( async (_token, _uid, _from, to: Date) => { - if (laterFailure && forcedStates.length) + if (laterFailure && forcedStates.length) { throw new Error('Forced reconciliation second window unavailable'); + } if (to.valueOf() === forcedAt.valueOf()) { beforeForced = await repository.read('uid', fullRange); const work = await repository.work( @@ -424,12 +428,16 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); if (laterFailure) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(beforeForced).toBeDefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await repository.read('uid', fullRange)).toEqual(beforeForced); - } else + } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((await repository.read('uid', covered)).data).toEqual([ { ...old, close: '200.000000001' }, ]); + } expect(forcedStates).toEqual(['running']); expect( await repository.work(userId, 'revision', 'candles:uid:all'), @@ -442,7 +450,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { now: forcedAt.toISOString(), }, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. if (userId === owner.userId) expect(forcedId).toBe(ordinaryId); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. else expect(forcedId).not.toBe(ordinaryId); }, ); @@ -495,8 +505,9 @@ describe('atomic shared candle reconciliation (SQLite)', () => { { instrumentUid: 'not-supported', type: 'FUND' }, { instrumentUid: null, type: 'BOND' }, ]); - for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) { await delay(5); + } expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid'); await service.read('owner', { uid: 'opened', @@ -510,7 +521,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); expect( new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])), - ).toEqual(new Set(['uid', 'opened'])); + ).toEqual(new Set(['opened', 'uid'])); const count = upstream.getCandles.mock.calls.length; await service.prefetch('owner', 'revision', [ { instrumentUid: 'uid', type: 'STOCK' }, diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts index 0868b8e..d263717 100644 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ b/test/tbank-catalog-discovery.e2e-spec.ts @@ -1,7 +1,7 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; @@ -10,6 +10,7 @@ import { type CatalogInstrument, type CatalogQuery, } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions prove query coverage, private work ownership and late-publication fences. describe('shared catalog discovery (SQLite)', () => { @@ -52,18 +53,20 @@ describe('shared catalog discovery (SQLite)', () => { }); async function settled() { for (let i = 0; i < 300; i++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Catalog work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -199,7 +202,7 @@ describe('shared catalog discovery (SQLite)', () => { await service.read('other', query); await delay(10); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -208,6 +211,7 @@ describe('shared catalog discovery (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve([instrument]); await settled(); expect(await prisma.tbankCatalogDiscovery.count()).toBe(0); diff --git a/test/tbank-catalog-local.e2e-spec.ts b/test/tbank-catalog-local.e2e-spec.ts index 551ab71..611b42a 100644 --- a/test/tbank-catalog-local.e2e-spec.ts +++ b/test/tbank-catalog-local.e2e-spec.ts @@ -1,34 +1,33 @@ -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; -import type { - StockCandlesResourceDto, - StockCompanyResourceDto, - StockQuoteResourceDto, -} from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { - BondMetricsResourceDto, - BondQuoteResourceDto, -} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; +import { + type BondMetricsResourceDto, + type BondQuoteResourceDto, type BondSearchResourceDto, +} from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; import { CatalogDiscoveryRepository } from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; -import type { - StockResourceDto, - StockSearchResourceDto, +import { + type StockCandlesResourceDto, + type StockCompanyResourceDto, + type StockQuoteResourceDto, + type StockResourceDto, + type StockSearchResourceDto, } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondSearchResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; // HTTP + disposable SQLite: missing/partial results, exact prices, shared source reuse and revision races. describe('local stock/bond search (SQLite HTTP)', () => { @@ -89,8 +88,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Catalog sources did not settle'); @@ -109,10 +109,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -162,7 +163,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { })), ), ); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -173,6 +174,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } }); afterAll(async () => app?.close()); @@ -187,8 +189,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('missing'); found.resolve([share]); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } const partial = await read(); expect(partial.data?.[0].instrumentUid).toBe('share'); expect(partial.meta.availability).toBe('partial'); @@ -329,16 +332,18 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await get('stocks?query=OTHER', 'other').expect(200); for ( let i = 0; i < 100 && (await prisma.tbankSyncWork.count({ where: { userId: 'other' } })) < 3; i++ - ) + ) { await delay(5); + } detail.resolve({ ...share, currency: 'rub', @@ -364,10 +369,11 @@ describe('local stock/bond search (SQLite HTTP)', () => { const detail = deferred(); upstream.getShare.mockReturnValueOnce(detail.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getShare.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -376,6 +382,7 @@ describe('local stock/bond search (SQLite HTTP)', () => { connectedAt: new Date(), }, }); + } detail.resolve(share); await settled(); expect( @@ -451,8 +458,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { ]); upstream.getLastPrices.mockReturnValueOnce(prices.promise); await read(false); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const initiating = await prisma.tbankSyncWork.findFirstOrThrow({ where: { userId: 'owner', scopeKey: { contains: 'last-price' } }, orderBy: { createdAt: 'asc' }, @@ -575,8 +583,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } const partial = ( await get(`stocks/${uid}/company?sync=observe`).expect(200) ).body as StockCompanyResourceDto; @@ -628,8 +637,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { const cold = (await get(`stocks/${uid}/quote`).expect(200)) .body as StockQuoteResourceDto; expect(cold.meta.availability).toBe('missing'); - for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getLastPrices.mock.calls.length; i++) { await delay(5); + } const pending = (await get(`stocks/${uid}/quote?sync=observe`).expect(200)) .body as StockQuoteResourceDto; expect(pending.data?.price).toBeNull(); @@ -858,8 +868,9 @@ describe('local stock/bond search (SQLite HTTP)', () => { let i = 0; i < 100 && !upstream.getAssetDescription.mock.calls.length; i++ - ) + ) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); company.resolve('Late company'); await settled(); diff --git a/test/tbank-connection.e2e-spec.ts b/test/tbank-connection.e2e-spec.ts index c23e12f..f85298d 100644 --- a/test/tbank-connection.e2e-spec.ts +++ b/test/tbank-connection.e2e-spec.ts @@ -1,14 +1,16 @@ +import type { Server } from 'node:http'; + import { INestApplication, NotFoundException } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { let app: INestApplication; @@ -35,10 +37,11 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -57,7 +60,7 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { .expect(401); expect(upstream.validateToken).not.toHaveBeenCalled(); const response = await put(' synthetic-token ').expect(200); - expect(Object.keys(response.body as object).sort()).toEqual([ + expect(Object.keys(response.body as object).sort((a, b) => a.localeCompare(b))).toEqual([ 'connected', 'connectedAt', ]); @@ -145,11 +148,12 @@ describe('Persistent T-Bank connection boundary (SQLite/HTTP)', () => { await expect( prisma.tbankOperation.create({ data: operation }), ).rejects.toMatchObject({ code: 'P2003' }); - for (let index = 0; index < 2; index += 1) + for (let index = 0; index < 2; index += 1) { await request(app.getHttpServer()) .delete('/api/v1/tbank-connection') .set('Authorization', `Bearer ${jwt('owner')}`) .expect(204); + } await expect( prisma.tbankOperation.create({ data: { ...operation, revision: replacement.revision }, diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 8a2f71a..9c08e50 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -1,19 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; +import assert from 'node:assert/strict'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { moscowDate } from '../src/modules/investments/investment-calendar'; +import { configureApp } from '../src/main'; import { IncomeAnalyticsResourceDto, IncomeSummaryResourceDto, } from '../src/modules/investments/income/income-analytics.dto'; +import { moscowDate } from '../src/modules/investments/investment-calendar'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Financial proof uses real persisted rows/coverage and the configured HTTP boundary. describe('local income completeness and exact money (SQLite)', () => { @@ -102,7 +104,7 @@ describe('local income completeness and exact money (SQLite)', () => { ['same', '10', 1, 'rub', 'OPERATION_TYPE_COUPON'], ['negative', '-2', -500000000, 'rub', 'OPERATION_TYPE_DIVIDEND'], ['usd', '3', 0, 'usd', 'OPERATION_TYPE_COUPON'], - ] as const) + ] as const) { await prisma.tbankOperation.create({ data: { userId: 'income-owner', @@ -119,6 +121,7 @@ describe('local income completeness and exact money (SQLite)', () => { }, }, }); + } } }); it('returns exact selected sums without a false zero comparison; reopen and observe need no RPC', async () => { diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index e2fb2dd..00e0036 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -1,18 +1,21 @@ -import assert from 'node:assert/strict'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + +import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; import { InstrumentMetadataRepository } from '../src/integrations/tbank/instrument-metadata.repository'; import { TbankClient } from '../src/integrations/tbank/tbank.client'; -import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { configureApp } from '../src/main'; import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; -import type { IncomeAnalyticsResourceDto } from '../src/modules/investments/income/income-analytics.dto'; +import { BrokerageOperationsResourceDto } from '../src/modules/investments/operations/presentation/brokerage-operation.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const uid = '11111111-1111-4111-8111-111111111111'; const period = 'from=2025-01-01&to=2025-01-31'; @@ -48,10 +51,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => app?.close()); beforeEach(async () => { @@ -84,7 +88,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { fetchedAt: new Date(), }, }); - for (const id of ['1', '2', '3']) + for (const id of ['1', '2', '3']) { await prisma.tbankOperation.create({ data: { userId: 'owner', @@ -103,10 +107,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }, }, }); + } } }); it('reads ordered pages with identical IDs/timestamps across accounts and no upstream RPC, including after reopen', async () => { - let cursor: string | null = null; + let cursor: null | string = null; const ids: string[] = []; do { const response = await get( @@ -186,7 +191,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { })), ).toEqual(expected); const items: { id: string; occurredAt: string }[] = []; - let cursor: string | null = null; + let cursor: null | string = null; do { const response = await request(app.getHttpServer()) .get( @@ -235,9 +240,9 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { occurredAt: new Date(observedAt.valueOf() - 1000) }, }); const query = - periodKind === 'default' - ? '' - : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; + periodKind === 'default' ? + '' : + `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; const page = (cursor?: string, observe = false) => request(app.getHttpServer()) .get( @@ -418,10 +423,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(cold.meta.availability).toBe('missing'); await get('sync=observe', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); + // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. unblock({ hasNext: false, items: [] }); for (let index = 0; index < 300; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const count = upstream.getOperationsByCursor.mock.calls.length; diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index be20a87..3536b66 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -1,20 +1,22 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { - TbankClient, type TbankAccount, + TbankClient, type TbankPortfolio, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { configureApp } from '../src/main'; import { BrokerageAccountsResourceDto } from '../src/modules/investments/brokerage-accounts/brokerage-account.dto'; +import { PortfolioResourceDto } from '../src/modules/investments/portfolio/presentation/portfolio.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -72,10 +74,11 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -336,9 +339,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ]); await put().expect(200); upstream.getPortfolio.mockImplementation((_token, id) => - id === 'second' - ? Promise.reject(new Error('synthetic unavailable')) - : Promise.resolve({ + id === 'second' ? + Promise.reject(new Error('synthetic unavailable')) : + Promise.resolve({ positions: [ { instrumentUid: uid, @@ -351,8 +354,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { ); await get('brokerage-accounts').expect(200); for (let index = 0; index < 200; index += 1) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { break; + } await delay(5); } const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) diff --git a/test/tbank-metadata-persistence.e2e-spec.ts b/test/tbank-metadata-persistence.e2e-spec.ts index fba3938..ef509bf 100644 --- a/test/tbank-metadata-persistence.e2e-spec.ts +++ b/test/tbank-metadata-persistence.e2e-spec.ts @@ -1,15 +1,17 @@ -import assert from 'node:assert/strict'; import { ConfigService } from '@nestjs/config'; +import assert from 'node:assert/strict'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; + +import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; + import { InstrumentMetadataRepository, metadataKey, } from '../src/integrations/tbank/instrument-metadata.repository'; -import type { TbankInstrument } from '../src/integrations/tbank/tbank.client'; +import { InstrumentMetadataService } from '../src/integrations/tbank/instrument-metadata.service'; +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { PrismaService } from '../src/prisma/prisma.service'; // Shared publication is proven against real FK/transaction/connection boundaries. describe('durable shared instrument metadata (SQLite)', () => { @@ -55,18 +57,20 @@ describe('durable shared instrument metadata (SQLite)', () => { }); async function settled() { for (let index = 0; index < 300; index++) { - if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) + if (!(await prisma.tbankSyncWork.count({ where: { state: 'running' } }))) { return; + } await delay(5); } throw new Error('Metadata work did not settle'); } beforeAll(async () => { await prisma.$connect(); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); afterAll(async () => prisma.$disconnect()); beforeEach(async () => { @@ -152,7 +156,7 @@ describe('durable shared instrument metadata (SQLite)', () => { await delay(10); await service.read('other', identity); await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); - if (action === 'replace') + if (action === 'replace') { await prisma.tbankConnection.create({ data: { userId: 'owner', @@ -161,6 +165,7 @@ describe('durable shared instrument metadata (SQLite)', () => { connectedAt: new Date(), }, }); + } blocked.resolve(known); await settled(); expect(await prisma.tbankInstrument.count()).toBe(0); @@ -346,9 +351,9 @@ describe('durable shared instrument metadata (SQLite)', () => { await service.read('owner', identity); await settled(); const identities = Array.from({ length: 1000 }, (_, index) => - index - ? { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } - : identity, + index ? + { instrumentUid: `uid-${index}`, figi: `FIGI-${index}` } : + identity, ); const saved = await service.savedMany(identities); expect(saved.size).toBe(1000); diff --git a/test/tbank-migration.e2e-spec.ts b/test/tbank-migration.e2e-spec.ts index db674e9..5f081dd 100644 --- a/test/tbank-migration.e2e-spec.ts +++ b/test/tbank-migration.e2e-spec.ts @@ -1,7 +1,9 @@ -import { readFileSync, readdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { DatabaseSync } from 'node:sqlite'; import { PrismaBetterSqlite3 } from '@prisma/adapter-better-sqlite3'; +import { readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +// eslint-disable-next-line n/no-unsupported-features/node-builtins -- The migration test intentionally uses node:sqlite from the pinned Node 24 runtime. +import { DatabaseSync } from 'node:sqlite'; + import { PrismaClient } from '../generated/prisma/client'; const migration = '20260924170000_persist_tbank_data'; @@ -25,8 +27,10 @@ describe('T-Bank additive migration on real SQLite', () => { db.exec('PRAGMA foreign_keys=ON'); for (const name of readdirSync(migrations) .filter((name) => name < migration && /^\d/.test(name)) - .sort()) + .sort((a, b) => a.localeCompare(b))) { + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. db.exec(readFileSync(join(migrations, name, 'migration.sql'), 'utf8')); + } if (populated) { db.exec(` INSERT INTO User (id,email,passwordHash,createdAt,updatedAt) VALUES @@ -90,7 +94,7 @@ describe('T-Bank additive migration on real SQLite', () => { adapter: new PrismaBetterSqlite3({ url: `file:${file}` }), }); try { - if (!populated) + if (!populated) { await prisma.user.create({ data: { id: 'owner', @@ -104,6 +108,7 @@ describe('T-Bank additive migration on real SQLite', () => { }, }, }); + } const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' }, }); diff --git a/test/tbank-operations-persistence.e2e-spec.ts b/test/tbank-operations-persistence.e2e-spec.ts index 507b3b5..4beff9f 100644 --- a/test/tbank-operations-persistence.e2e-spec.ts +++ b/test/tbank-operations-persistence.e2e-spec.ts @@ -1,6 +1,7 @@ import { ConfigService } from '@nestjs/config'; -import { PrismaService } from '../src/prisma/prisma.service'; + import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; // Real transactions are the boundary: staged pages never become financial // results, and a failed multi-account publication must preserve every old row. @@ -106,11 +107,12 @@ describe('Operations staging and reconciliation (isolated SQLite)', () => { } })(), ]); - for (const snapshot of snapshots) + for (const snapshot of snapshots) { expect([ ['10', '10'], ['20', '20'], ]).toContainEqual(snapshot); + } await reader.$disconnect(); await reader.$connect(); expect( diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index ac70f75..0937213 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -3,15 +3,17 @@ import { fork } from 'node:child_process'; import { once } from 'node:events'; import { join } from 'node:path'; import { setTimeout as delay } from 'node:timers/promises'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; + import type { TbankOperationsByCursorRequest, TbankOperationsByCursorResponse, } from '../src/integrations/tbank/tbank.client'; + +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; import { BrokerageOperationsSyncService } from '../src/modules/investments/operations/application/brokerage-operations-sync.service'; +import { BrokerageOperationsRepository } from '../src/modules/investments/operations/infrastructure/brokerage-operations.repository'; +import { PrismaService } from '../src/prisma/prisma.service'; function deferred() { let resolve: (value: T) => void = () => { @@ -154,11 +156,12 @@ describe('Request-driven operations synchronization (SQLite)', () => { upstream.getOperationsByCursor .mockReturnValueOnce(gate.promise) .mockImplementation((_token, request) => { - if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) + if (Number(request.from?.seconds) + 1 === march.from.valueOf() / 1000) { return Promise.resolve({ hasNext: false, items: [operation('20', '1773000000')], }); + } return Promise.reject(new Error('unrelated history unavailable')); }); const history = requiredHandle( @@ -332,8 +335,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { if ( failFebruary && request.from?.seconds === String(february.from.valueOf() / 1000 - 1) - ) + ) { return Promise.reject(new Error('Second month unavailable')); + } return Promise.resolve({ hasNext: false, items: [ @@ -431,22 +435,29 @@ describe('Request-driven operations synchronization (SQLite)', () => { await delay(10); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); if (checkpoint === 'after-publish') { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBe(0); } else { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(0); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankSyncStage.count()).toBeGreaterThan(0); upstream.getOperationsByCursor.mockResolvedValueOnce(complete); await service.request('owner', [january], true); await state(handle, 'complete'); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( upstream.getOperationsByCursor.mock.calls[0][1].cursor, ).toBeUndefined(); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(await prisma.tbankOperation.count()).toBe(1); } } finally { - if (child.exitCode === null && child.signalCode === null) + if (child.exitCode === null && child.signalCode === null) { child.kill('SIGKILL'); + } await exited; } }, @@ -492,8 +503,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); expect(upstream.getOperationsByCursor.mock.calls[1][1].from).toEqual({ seconds: String(early.to.valueOf() / 1000 - 1), @@ -507,8 +519,8 @@ describe('Request-driven operations synchronization (SQLite)', () => { it('lets new screen work overtake history between provider pages', async () => { const first = deferred<{ hasNext: boolean; - nextCursor: string; items: never[]; + nextCursor: string; }>(); upstream.getOperationsByCursor .mockReturnValueOnce(first.promise) @@ -542,8 +554,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(await prisma.tbankOperation.count()).toBe(1); const before = await prisma.tbankOperationCoverage.findMany(); expect(before).toHaveLength(1); @@ -675,8 +688,9 @@ describe('Request-driven operations synchronization (SQLite)', () => { let index = 0; index < 500 && upstream.getOperationsByCursor.mock.calls.length < 2; index += 1 - ) + ) { await delay(2); + } expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(2); const before = await prisma.tbankSyncWork.findUniqueOrThrow({ where: { id: handle }, @@ -708,7 +722,7 @@ describe('Request-driven operations synchronization (SQLite)', () => { }); }); -function requiredHandle(value: string | null): string { +function requiredHandle(value: null | string): string { if (!value) throw new Error('Expected sync handle'); return value; } diff --git a/test/tbank-read-baseline.e2e-spec.ts b/test/tbank-read-baseline.e2e-spec.ts index 56cf046..5a16f08 100644 --- a/test/tbank-read-baseline.e2e-spec.ts +++ b/test/tbank-read-baseline.e2e-spec.ts @@ -1,23 +1,29 @@ +import type { NextFunction, Request, Response } from 'express'; +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; -import { cpus, totalmem } from 'node:os'; -import { writeFileSync, statSync, readFileSync } from 'node:fs'; -import { setTimeout as delay } from 'node:timers/promises'; -import type { Request, Response, NextFunction } from 'express'; +import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, statSync, writeFileSync } from 'node:fs'; +import { cpus, tmpdir, totalmem } from 'node:os'; +import { join } from 'node:path'; import { performance } from 'node:perf_hooks'; +import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; +import { MoexClient } from '../src/integrations/moex/moex.api'; +import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankClient, TbankOperationsByCursorRequest, } from '../src/integrations/tbank/tbank.client'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { configureApp } from '../src/main'; import { PrismaService } from '../src/prisma/prisma.service'; -import { MoexClient } from '../src/integrations/moex/moex.api'; -import { NcrRatingsClient } from '../src/integrations/ncr/ncr.api'; + +const evidenceDirectory = mkdtempSync(join(tmpdir(), 'wealthledger-read-evidence-')); // Same navigation and synthetic sources as the saved baseline; isolated SQLite only. describe('T-Bank persisted read budget', () => { @@ -52,9 +58,9 @@ describe('T-Bank persisted read budget', () => { const operations = Array.from({ length: 100_000 }, (_, index) => { // 1,000 measured-month rows, 200 income receipts, remaining rows over 10 years. const time = - index < 1000 - ? Date.UTC(2026, 7, 1) + index * 2_500_000 - : Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; + index < 1000 ? + Date.UTC(2026, 7, 1) + index * 2_500_000 : + Date.UTC(2016, 8, 1) + (index - 1000) * 3_160_000; const instrument = instruments[index % instruments.length]; return { id: `operation-${index}`, @@ -69,15 +75,17 @@ describe('T-Bank persisted read budget', () => { }; }).sort((a, b) => Number(b.date.seconds) - Number(a.date.seconds)); const rpc: Record = {}; - let upstreamMode: 'ready' | 'delayed' | 'failed' = 'ready'; + let upstreamMode: 'delayed' | 'failed' | 'ready' = 'ready'; let sourceGate: Promise | undefined; let releaseSource = () => {}; const serverTimes: number[] = []; async function counted(name: string, value: T): Promise { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. rpc[name] = (rpc[name] ?? 0) + 1; if (upstreamMode === 'delayed') await sourceGate; - if (upstreamMode === 'failed') + if (upstreamMode === 'failed') { return Promise.reject(new Error('Synthetic outage')); + } return Promise.resolve(value); } const upstream = { @@ -261,7 +269,7 @@ describe('T-Bank persisted read budget', () => { historyBoundary: 'openedOn', }, }); - for (let offset = 0; offset < operations.length; offset += 1000) + for (let offset = 0; offset < operations.length; offset += 1000) { await prisma.tbankOperation.createMany({ data: operations.slice(offset, offset + 1000).map((row) => ({ ...scope, @@ -272,6 +280,7 @@ describe('T-Bank persisted read budget', () => { payload: row, })), }); + } await prisma.tbankOperationCoverage.create({ data: { ...scope, @@ -289,9 +298,9 @@ describe('T-Bank persisted read budget', () => { uid: instrument.uid, ticker: instrument.ticker, instrumentType: - instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' - ? 'share' - : 'bond', + instrument.instrumentKind === 'INSTRUMENT_TYPE_SHARE' ? + 'share' : + 'bond', metadata: instrument, fetchedAt: now, }, @@ -329,8 +338,8 @@ describe('T-Bank persisted read budget', () => { ], }); } - expect(await prisma.tbankOperation.count()).toBe(300_000); - expect(await prisma.tbankCandle.count()).toBe(1_000_000); + assert.equal(await prisma.tbankOperation.count(), 300_000); + assert.equal(await prisma.tbankCandle.count(), 1_000_000); }, 300_000); afterAll(async () => app?.close()); @@ -368,8 +377,9 @@ describe('T-Bank persisted read budget', () => { !(await app .get(PrismaService) .tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } throw new Error('Import did not settle'); } @@ -397,6 +407,7 @@ describe('T-Bank persisted read budget', () => { await fresh(); } const warmupRpc = { ...rpc }; + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. for (const key of Object.keys(rpc)) rpc[key] = 0; await app.close(); await start(); @@ -411,21 +422,26 @@ describe('T-Bank persisted read budget', () => { .set('Authorization', `Bearer ${token}`) .expect(200); const body = response.body as { - meta: { availability: string }; data: unknown; + meta: { availability: string }; }; - if (route.includes('candles?period=all')) + if (route.includes('candles?period=all')) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect((body.data as { candles: unknown[] }).candles).toHaveLength( 5000, ); + } expect({ route, availability: body.meta.availability }).toEqual({ route, availability: 'complete', }); + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. if (!responses[route]) { + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. responses[route] = body; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-browser-responses.json', + join(evidenceDirectory, 'browser-responses.json'), JSON.stringify(responses), ); } @@ -439,8 +455,9 @@ describe('T-Bank persisted read budget', () => { serverTimes.length = 0; const before = { ...rpc }; const samples = []; - for (let batch = 0; batch < 10; batch += 1) + for (let batch = 0; batch < 10; batch += 1) { samples.push(...(await Promise.all(Array.from({ length: 10 }, read)))); + } const clientTimes = samples .map((sample) => sample.ms) .sort((a, b) => a - b); @@ -459,6 +476,7 @@ describe('T-Bank persisted read budget', () => { rpc: Object.fromEntries( Object.entries(rpc).map(([key, count]) => [ key, + // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. count - (before[key] ?? 0), ]), ), @@ -469,13 +487,13 @@ describe('T-Bank persisted read budget', () => { runtime: process.version, hardware: { cpuQuota: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/cpu.max', 'utf8').trim() : + null, memoryLimit: - process.platform === 'linux' - ? readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() - : null, + process.platform === 'linux' ? + readFileSync('/sys/fs/cgroup/memory.max', 'utf8').trim() : + null, cpu: cpus()[0]?.model, cpus: cpus().length, memoryBytes: totalmem(), @@ -495,6 +513,7 @@ describe('T-Bank persisted read budget', () => { evidence, totalRpc: { ...rpc }, warmupRpc, + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths stay in runner-created temporary directories or explicitly selected local test builds. databaseBytes: statSync( String(process.env.DATABASE_URL).replace('file:', ''), ).size, @@ -513,11 +532,12 @@ describe('T-Bank persisted read budget', () => { newRpc, reduction: 1 - newRpc / oldRpc, }; + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); - console.log(JSON.stringify(output)); + console.log(JSON.stringify({ ...output, evidenceDirectory })); // Hold the source until HTTP reads finish: proves independence without a // wall-clock race against the runner's throughput. The p95 gate stays below. await fresh(); @@ -572,8 +592,9 @@ describe('T-Bank persisted read budget', () => { staleLogicalPass: { portfolio: 1, positions: 1, retries: 0 }, coldImportMs, }); + // eslint-disable-next-line security/detect-non-literal-fs-filename -- Paths are under an exclusive mkdtemp directory or the local migrations directory. writeFileSync( - '/tmp/wealthledger-persistent-budget.json', + join(evidenceDirectory, 'persistent-budget.json'), JSON.stringify(output, null, 2), ); expect(newRpc).toBe(0); diff --git a/test/tbank-screen-sync.e2e-spec.ts b/test/tbank-screen-sync.e2e-spec.ts index 6c75d5b..f319d78 100644 --- a/test/tbank-screen-sync.e2e-spec.ts +++ b/test/tbank-screen-sync.e2e-spec.ts @@ -1,15 +1,17 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; import { setTimeout as delay } from 'node:timers/promises'; -import type { Server } from 'node:http'; import request from 'supertest'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; -import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { TbankClient } from '../src/integrations/tbank/tbank.client'; +import { configureApp } from '../src/main'; import { TbankSyncStatusDto } from '../src/modules/investments/tbank-sync/tbank-sync.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; describe('screen sync HTTP and SQLite', () => { let app: INestApplication; @@ -51,10 +53,11 @@ describe('screen sync HTTP and SQLite', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await prisma.tbankConnection.deleteMany(); @@ -62,7 +65,7 @@ describe('screen sync HTTP and SQLite', () => { upstream.getOpenAccounts.mockResolvedValue([]); upstream.getPortfolio.mockResolvedValue({ positions: [] }); upstream.getPositions.mockResolvedValue({ money: [] }); - for (const userId of ['owner', 'other']) + for (const userId of ['owner', 'other']) { await prisma.tbankConnection.create({ data: { userId, @@ -72,6 +75,7 @@ describe('screen sync HTTP and SQLite', () => { membershipRevision: 1, }, }); + } }); afterAll(async () => app.close()); it('accepts before blocked RPC finishes, joins identical requests, and observes without starting work', async () => { @@ -145,9 +149,9 @@ describe('screen sync HTTP and SQLite', () => { screen: 'stock', instrumentUid: '00000000-0000-4000-8000-000000000001', candleRange: { - from: '2026-02-01', - to: '2026-01-01', - jobs: [], + 'from': '2026-02-01', + 'to': '2026-01-01', + 'jobs': [], '~bad/path': 'SYNTHETIC_VALIDATION_SECRET', }, }, @@ -158,7 +162,7 @@ describe('screen sync HTTP and SQLite', () => { ); const problem = response.body as { code: string; - errors: { pointer: string; detail: string }[]; + errors: { detail: string; pointer: string }[]; }; expect(problem.code).toBe('VALIDATION_ERROR'); expect(problem.errors.length).toBeGreaterThan(0); @@ -167,15 +171,19 @@ describe('screen sync HTTP and SQLite', () => { expect(typeof error.detail).toBe('string'); } if ('candleRange' in scope) { + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect( problem.errors.filter( (error) => error.pointer === '#/candleRange/jobs', ), ).toHaveLength(1); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(problem.errors).toContainEqual({ pointer: '#/candleRange/~0bad~1path', + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. detail: expect.any(String) as unknown, }); + // eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants. expect(JSON.stringify(response.body)).not.toContain( 'SYNTHETIC_VALIDATION_SECRET', ); @@ -350,9 +358,9 @@ describe('screen sync HTTP and SQLite', () => { }), ) .mockImplementation(() => - outcome === 'failed' - ? Promise.reject(new Error('Synthetic outage')) - : Promise.resolve({ hasNext: false, items: [] }), + outcome === 'failed' ? + Promise.reject(new Error('Synthetic outage')) : + Promise.resolve({ hasNext: false, items: [] }), ); const accept = async (from: string, to: string) => { const response = await post({ diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index 33c0f02..d19ee82 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -1,23 +1,26 @@ +import type { Server } from 'node:http'; + import { INestApplication } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import { Test } from '@nestjs/testing'; -import type { Server } from 'node:http'; import { setTimeout as delay } from 'node:timers/promises'; import request from 'supertest'; + +import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; +import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; + import { AppModule } from '../src/app.module'; -import { configureApp } from '../src/main'; -import { PrismaService } from '../src/prisma/prisma.service'; +import { MoexClient } from '../src/integrations/moex/moex.api'; import { TbankClient, - TbankTokenService, tbankSourceTtlMs, + TbankTokenService, } from '../src/integrations/tbank/tbank.api'; -import { MoexClient } from '../src/integrations/moex/moex.api'; +import { configureApp } from '../src/main'; import { CatalogEnrichmentService } from '../src/modules/investments/instruments/application/catalog-enrichment.service'; import { ForecastRepository } from '../src/modules/investments/portfolio/infrastructure/forecast.repository'; -import type { UpcomingEventsResourceDto } from '../src/modules/investments/upcoming-events/upcoming-events.dto'; -import type { StockDividendsResourceDto } from '../src/modules/investments/instruments/stocks/presentation/stock.dto'; -import type { BondCouponsResourceDto } from '../src/modules/investments/instruments/bonds/presentation/bond.dto'; +import { PrismaService } from '../src/prisma/prisma.service'; const shareUid = '00000000-0000-4000-8000-000000000001'; const bondUid = '00000000-0000-4000-8000-000000000002'; @@ -86,8 +89,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await delay(10); if ( !(await prisma.tbankSyncWork.count({ where: { state: 'running' } })) - ) + ) { return; + } } } throw new Error('Forecast dependencies did not settle'); @@ -139,10 +143,11 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await app.init(); await app.listen(0, '127.0.0.1'); prisma = app.get(PrismaService); - for (const id of ['owner', 'other']) + for (const id of ['owner', 'other']) { await prisma.user.create({ data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, }); + } }); beforeEach(async () => { await settled(); @@ -236,8 +241,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const cold = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; expect(cold.data).toBeNull(); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await events().expect(200); release([]); await settled(); @@ -290,9 +296,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .body as UpcomingEventsResourceDto; await portfolio([position(shareUid, '7'), position(newUid, '5')]); upstream.getDividends.mockImplementation((_token: string, uid: string) => - uid === newUid - ? Promise.reject(new Error('new calendar unavailable')) - : Promise.resolve([]), + uid === newUid ? + Promise.reject(new Error('new calendar unavailable')) : + Promise.resolve([]), ); const pending = (await events(false).expect(200)) .body as UpcomingEventsResourceDto; @@ -450,12 +456,13 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const path = `${kind}/${uid}/${payout}`; const read = async () => (await get(`${path}?sync=observe`).expect(200)).body as - StockDividendsResourceDto | BondCouponsResourceDto; + BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); const before = await read(); - if (!before.data?.positionAt) + if (!before.data?.positionAt) { throw new Error('Expected a saved position'); + } expect(before.data.positionIsStale).toBe(false); const saved = await prisma.tbankForecast.findFirstOrThrow(); const expiresAt = @@ -504,8 +511,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { } finally { publish.mockRestore(); } - for (const rpc of Object.values(upstream)) + for (const rpc of Object.values(upstream)) { expect(rpc).not.toHaveBeenCalled(); + } expect(moex.bondEvents).not.toHaveBeenCalled(); } finally { clock.mockRestore(); @@ -615,8 +623,9 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { }), ); await events(false).expect(200); - for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) + for (let i = 0; i < 100 && !upstream.getDividends.mock.calls.length; i++) { await delay(5); + } await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); release([ {