chore: strengthen backend eslint and value policies #40

Merged
ksv741 merged 7 commits from codex/backend-eslint into develop 2026-09-28 16:37:02 +03:00
192 changed files with 4531 additions and 2166 deletions
+17 -10
View File
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные `modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
публичные входы для protobuf conversion и interval rules: импорт расчётов публичные входы для protobuf conversion, protocol/error vocabulary и interval
не загружает DI/Prisma. rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты. Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks. Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются Новый export требует текущего потребителя; controller и persistence остаются
@@ -235,8 +236,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
Общий выбор symbolic values и числовых policies — Общий выбор symbolic values и числовых policies —
[Domain values](conventions/engineering.md#domain-values). [Domain values](conventions/engineering.md#domain-values).
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает `unknown` + runtime narrowing; ESLint запрещает explicit `any`
`any` нормой domain/application model. `declare` допустим для заполняемых извне в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
DTO fields, не для сокрытия неинициализированного owned state. DTO fields, не для сокрытия неинициализированного owned state.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
@@ -246,11 +247,17 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
сохраняй; generated Prisma/protocol code вручную не меняй. сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
индексов, default values, enum/type declarations и readonly properties заданы значения, проверяемые правилом, получают предметные имена у владельца.
в ESLint config. Test fixtures/expected values остаются inline, остальные lint Исключения для индексов, default values, enum/type declarations и readonly
правила продолжают действовать. Numeric policy получает предметное имя и единицы; properties заданы в ESLint config. Test fixtures/expected values остаются inline,
semantic magic strings проверяются conventions/review без нового инструмента. остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
Machine codes и protocol values задаются строковыми enum у владельца.
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
## Logging ## Logging
+2 -1
View File
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную. Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit. обычные labels не выноси в constants без semantic benefit.
+3 -1
View File
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
Преобразование provider records происходит на boundary: например, Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
не должны загружать DI/Prisma через service exports. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов. на границе клиента/его mapping, а не становятся моделью чистых расчётов.
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+28
View File
@@ -122,6 +122,34 @@ Required checks настраиваются отдельно в Gitea: налич
runtime-валидацию. В обычном коде нужны инициализация или сужение типа 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 ## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
+2 -2
View File
@@ -1,4 +1,4 @@
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ['@commitlint/config-conventional'],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
}; };
+206
View File
@@ -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
}
}
}
+136 -57
View File
@@ -1,59 +1,91 @@
// @ts-check // @ts-check
import nest from '@darraghor/eslint-plugin-nestjs-typed';
import eslint from '@eslint/js'; 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 globals from 'globals';
import tseslint from 'typescript-eslint'; 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/'], files: ['**/*.{ts,js,cjs,mjs}'],
}, extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, node.configs['flat/recommended'],
eslintPluginPrettierRecommended, importX.flatConfigs.recommended,
{ sonarjs.configs.recommended,
languageOptions: { regexp.configs['flat/recommended'],
globals: { security.configs.recommended,
...globals.node, perfectionist.configs['recommended-natural'],
...globals.jest, stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
}, ],
sourceType: 'commonjs', languageOptions: { globals: globals.node },
parserOptions: { linterOptions: { reportUnusedDisableDirectives: 'error' },
projectService: true, settings: {
tsconfigRootDir: import.meta.dirname, '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',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
{ {
files: ['**/*.mjs'],
extends: [node.configs['flat/recommended-module']],
},
{
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
rules: { rules: {
// These scripts run after build; lint validates their source paths before dist exists.
'n/no-missing-require': ['error', {
resolverConfig: {
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
extensionAlias: { '.js': ['.ts', '.js'] },
},
}],
},
},
{
files: ['**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
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', 'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [ '@typescript-eslint/no-magic-numbers': [
'error', 'error',
{ {
ignore: [
-60,
-24,
-10,
-2,
-1,
0,
1,
2,
10,
24,
60,
'-60n',
'-24n',
'-10n',
'-2n',
'-1n',
'0n',
'1n',
'2n',
'10n',
'24n',
'60n',
],
ignoreArrayIndexes: true, ignoreArrayIndexes: true,
ignoreDefaultValues: true, ignoreDefaultValues: true,
ignoreEnums: true, ignoreEnums: true,
@@ -63,24 +95,71 @@ export default tseslint.config(
enforceConst: true, enforceConst: true,
}, },
], ],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [ 'no-restricted-syntax': ['error', {
'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.',
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' }],
}, },
}, },
{ {
files: ['**/*.spec.ts', 'test/**/*.ts'], files: ['src/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' }, 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'] }],
},
},
{
files: [
'src/**/*.ts',
],
ignores: [
'**/*.spec.ts',
'test/**/*.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
message: 'Use the owning enum member for machine codes and protocol values.',
},
{
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
message: 'Use the owning enum member instead of a template machine code.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
message: 'Use an enum member instead of an inline code or protocol value.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
message: 'Use an enum member instead of constructing a code or protocol value.',
},
{
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
message: 'Use a computed enum member as the protocol mapping key.',
},
{
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
message: 'Compare codes with their enum members, not string literals.',
},
{
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
}, },
); );
+754 -215
View File
File diff suppressed because it is too large Load Diff
+19 -11
View File
@@ -11,14 +11,14 @@
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs", "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": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint . --fix --max-warnings=0",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -30,10 +30,11 @@
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", "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:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint" "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.4",
@@ -53,6 +54,7 @@
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"dotenv": "17.4.2",
"lru-cache": "11.5.2", "lru-cache": "11.5.2",
"nestjs-pino": "5.1.0", "nestjs-pino": "5.1.0",
"passport": "0.7.0", "passport": "0.7.0",
@@ -65,12 +67,13 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "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", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0", "@nestjs/cli": "12.0.0",
"@nestjs/schematics": "12.0.1", "@nestjs/schematics": "12.0.1",
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.0.1",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.0",
"@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
@@ -79,12 +82,17 @@
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"eslint": "10.10.0", "eslint": "10.11.0",
"eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-prettier": "5.5.6", "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", "globals": "17.12.0",
"jest": "30.5.1", "jest": "30.5.1",
"prettier": "3.9.6",
"release-it": "21.0.2", "release-it": "21.0.2",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"supertest": "7.2.2", "supertest": "7.2.2",
@@ -93,7 +101,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.70.0" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"@nestjs/platform-express": { "@nestjs/platform-express": {
+11 -9
View File
@@ -1,10 +1,12 @@
const assert = require("node:assert/strict"); const assert = require('node:assert/strict');
const { execFileSync } = require("node:child_process"); const { execFileSync } = require('node:child_process');
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); const { version } = require('../package.json');
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
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."); 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.');
+1
View File
@@ -15,6 +15,7 @@ Object.assign(process.env, {
}); });
const { NestFactory } = require('@nestjs/core'); const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js'); const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
+2 -1
View File
@@ -1,8 +1,8 @@
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { Environment, validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module'; 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 { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.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 { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
+45 -23
View File
@@ -1,12 +1,30 @@
export type Decimal = { value: bigint; scale: number }; export const DECIMAL_RADIX = 10n;
const SIGN_LENGTH = 1;
const NEGATIVE_SIGN = -1n;
const POSITIVE_SIGN = 1n;
const ZERO_UNITS = 0n;
const EQUAL = 0;
const GREATER_THAN = 1;
const LESS_THAN = -1;
const INTEGER_SCALE = 0;
const MIN_WHOLE_DIGITS = 1;
const DIGITS_START = 0;
const PREVIOUS_DIGIT_OFFSET = 1;
const DIGIT_STEP = 1;
const ROUNDING_DIVISOR = 2n;
export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal { 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'); throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-'); const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
return { return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
scale: fraction.length, scale: fraction.length,
}; };
} }
@@ -15,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale); const scale = Math.max(left.scale, right.scale);
return { return {
value: value:
left.value * 10n ** BigInt(scale - left.scale) + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale), right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
scale, scale,
}; };
} }
@@ -30,33 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
} }
export function isZero(value: Decimal): boolean { export function isZero(value: Decimal): boolean {
return value.value === 0n; return value.value === ZERO_UNITS;
} }
export function compare(left: Decimal, right: Decimal): number { export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value; const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1; return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
} }
export function formatDecimal({ value, scale }: Decimal): string { export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0'; if (value === ZERO_UNITS) return '0';
const sign = value < 0n ? '-' : ''; const sign = value < ZERO_UNITS ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`; if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
const digits = (value < 0n ? -value : value) const digits = (value < ZERO_UNITS ? -value : value)
.toString() .toString()
.padStart(scale + 1, '0'); .padStart(scale + MIN_WHOLE_DIGITS, '0');
const whole = digits.slice(0, -scale) || '0'; const whole = digits.slice(DIGITS_START, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, ''); const rawFraction = digits.slice(-scale);
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`;
} }
// Round half away from zero; callers choose presentation precision explicitly. // Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal { export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero'); if (right.value === ZERO_UNITS) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale); const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale); const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
const n = numerator < 0n ? -numerator : numerator; const n = numerator < ZERO_UNITS ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator; const d = denominator < ZERO_UNITS ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale }; return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
} }
@@ -1,20 +1,23 @@
import { HttpStatus, applyDecorators } from '@nestjs/common'; import { applyDecorators, HttpStatus } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
ApiPropertyOptional,
ApiResponse, ApiResponse,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
declare status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string; declare code: string;
@ApiProperty({ // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
required: false, @ApiPropertyOptional({
type: 'array', type: 'array',
items: { items: {
type: 'object', type: 'object',
@@ -23,7 +26,7 @@ export class ProblemDetailsDto {
}, },
example: [{ pointer: '#/email', detail: 'must be a valid email address' }], 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) { function problemResponse(status: number, description: string) {
@@ -38,7 +41,7 @@ function problemResponse(status: number, description: string) {
}); });
} }
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
@@ -6,7 +7,8 @@ import {
} from '../modules/banking/bank-cards/presentation/bank-card.dto'; } from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => { 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 () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto }; const metadata = { type: 'body' as const, metatype: CredentialsDto };
+8
View File
@@ -0,0 +1,8 @@
export enum HttpErrorCode {
BadRequest = 'BAD_REQUEST',
InternalServerError = 'INTERNAL_SERVER_ERROR',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
ValidationError = 'VALIDATION_ERROR',
}
@@ -1,4 +1,5 @@
import { ArgumentsHost } from '@nestjs/common'; import { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
+15 -10
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@@ -6,7 +8,8 @@ import {
HttpStatus, HttpStatus,
ValidationError, ValidationError,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { HttpErrorCode } from '../error-code';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
@@ -24,9 +27,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation const code = isValidation
? 'VALIDATION_ERROR' ? HttpErrorCode.ValidationError
: this.codeFor(status, payload); : this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code; response.locals ??= {};
response.locals.publicErrorCode = code;
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
@@ -49,20 +53,21 @@ export class HttpExceptionFilter implements ExceptionFilter {
if (typeof code === 'string') return code; if (typeof code === 'string') return code;
} }
return ( 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', 400: HttpErrorCode.BadRequest,
401: 'UNAUTHORIZED', 401: HttpErrorCode.Unauthorized,
404: 'NOT_FOUND', 404: HttpErrorCode.NotFound,
415: 'UNSUPPORTED_MEDIA_TYPE', 415: HttpErrorCode.UnsupportedMediaType,
500: 'INTERNAL_SERVER_ERROR', 500: HttpErrorCode.InternalServerError,
}[status] ?? 'INTERNAL_SERVER_ERROR' }[status] ?? HttpErrorCode.InternalServerError
); );
} }
private validationErrors( private validationErrors(
errors: ValidationError[], errors: ValidationError[],
prefix = '#', prefix = '#',
): { pointer: string; detail: string }[] { ): { detail: string; pointer: string }[] {
return errors.flatMap((error) => { return errors.flatMap((error) => {
if (!error || typeof error.property !== 'string') return []; if (!error || typeof error.property !== 'string') return [];
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
+1
View File
@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { RequestLoggingMiddleware } from './request-logging.middleware';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks'; import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = { export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: string; brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
}; };
@Injectable() @Injectable()
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => { describe('RequestLoggingMiddleware', () => {
@@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => {
const next = jest.fn(); const next = jest.fn();
const request: { const request: {
method: string;
baseUrl: string; baseUrl: string;
route: { path: string };
header: jest.Mock<string>; header: jest.Mock<string>;
id?: string; id?: string;
method: string;
route: { path: string };
} = { } = {
method: 'GET', method: 'GET',
baseUrl: '/api/v1', baseUrl: '/api/v1',
@@ -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 type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
@@ -47,8 +49,8 @@ export class RequestLoggingMiddleware implements NestMiddleware {
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error' ? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || : response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn' ? 'warn'
: 'info'; : 'info';
this.logger.event(level, 'http.request.completed', context); this.logger.event(level, 'http.request.completed', context);
+17 -15
View File
@@ -1,27 +1,28 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, string | number | boolean | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'service',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'operation',
'grpcCode',
'attempts', 'attempts',
'brokerageAccountId',
'clientRequestId',
'code',
'count', 'count',
'durationMs',
'environment',
'found', 'found',
'grpcCode',
'method',
'operation',
'requestId',
'route',
'service',
'status',
'userId',
]); ]);
@Injectable() @Injectable()
@@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string, message: string,
context: LogContext = {}, context: LogContext = {},
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext({ ...this.requestContext.get(), ...context }),
message, message,
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types'; import type { Money } from './money.types';
export class MoneyDto implements Money { export class MoneyDto implements Money {
+2
View File
@@ -1,2 +1,4 @@
export const ISO_DATE_LENGTH = 10;
export const ISO_DATE_START = 0;
export const MILLISECONDS_PER_SECOND = 1_000; export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000; export const MILLISECONDS_PER_DAY = 86_400_000;
+9 -6
View File
@@ -1,3 +1,6 @@
const MIN_PORT = 1;
const MIN_REQUESTS_PER_SECOND = 1;
const DEFAULT_PORT = 3000; const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535; const MAX_PORT = 65_535;
@@ -7,12 +10,12 @@ export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
JWT_SECRET: string; JWT_SECRET: string;
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
PORT: number; PORT: number;
TOKEN_ENCRYPTION_KEY: string;
TBANK_CA_CERT_PATH?: string; TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string; TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number; TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; TOKEN_ENCRYPTION_KEY: string;
} }
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
const parsedPort = Number(port); const parsedPort = Number(port);
if ( if (
!Number.isInteger(parsedPort) || !Number.isInteger(parsedPort) ||
parsedPort < 1 || parsedPort < MIN_PORT ||
parsedPort > MAX_PORT parsedPort > MAX_PORT
) { ) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
@@ -71,7 +74,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
if ( if (
typeof tokenEncryptionKey !== 'string' || 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 Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedTbankRateLimit = Number(tbankRateLimit); const parsedTbankRateLimit = Number(tbankRateLimit);
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) { if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer'); throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
} }
@@ -92,7 +95,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
throw new Error('TBANK_CA_CERT_PATH must be a file path'); 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'); throw new Error('LOG_LEVEL must be debug, info, warn, or error');
} }
+10 -5
View File
@@ -199,8 +199,9 @@ describe('MoexClient', () => {
const url = new URL(input instanceof Request ? input.url : input); const url = new URL(input instanceof Request ? input.url : input);
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(url.searchParams.get('start') ?? ''); starts.push(url.searchParams.get('start') ?? '');
if (starts.length > 1) if (starts.length > 1) {
return Promise.reject(new Error('Repeated page')); return Promise.reject(new Error('Repeated page'));
}
} }
return Promise.resolve( return Promise.resolve(
Response.json({ Response.json({
@@ -228,13 +229,14 @@ describe('MoexClient', () => {
const start = Number(url.searchParams.get('start')); const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start)); starts.push(String(start));
if (starts.length > 2) if (starts.length > 2) {
return Promise.reject(new Error('Repeated page')); return Promise.reject(new Error('Repeated page'));
}
} }
return Promise.resolve( return Promise.resolve(
Response.json({ Response.json({
...emptyEvents, ...emptyEvents,
coupons: { 'coupons': {
columns: ['coupondate', 'value', 'faceunit'], columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
}, },
@@ -246,11 +248,13 @@ describe('MoexClient', () => {
); );
}); });
const result = new MoexClient().bondEvents('BOND'); 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({ await expect(result).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' }, 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([ await expect(result).resolves.toEqual([
{ {
type: 'coupon', type: 'coupon',
@@ -265,6 +269,7 @@ describe('MoexClient', () => {
currency: 'RUB', currency: 'RUB',
}, },
]); ]);
}
expect(starts).toEqual(['0', '1']); expect(starts).toEqual(['0', '1']);
}, },
); );
+50 -30
View File
@@ -1,5 +1,13 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1;
const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
@@ -7,18 +15,18 @@ const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
export type MoexBondEvent = export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string } | { currency: string; date: string; type: 'coupon'; value: string } |
| { type: 'maturity'; date: string; value: string; currency: string } { currency: string; date: string; type: 'maturity'; value: string } |
| { type: 'offer'; date: string }; { date: string; type: 'offer' };
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = { export type MoexBondYield = {
value: string;
pricePercent: string;
date: string; date: string;
dateType: string | null; dateType: null | string;
pricePercent: string;
tradeAt: string; tradeAt: string;
value: string;
}; };
@Injectable() @Injectable()
@@ -52,14 +60,15 @@ export class MoexClient {
if ( if (
value === null || value === null ||
pricePercent === null || pricePercent === null ||
Number(pricePercent) <= 0 || Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
!date || !date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
!tradeAt || !tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
) ) {
return null; return null;
}
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null; if (!Number.isFinite(Date.parse(tradeIso))) return null;
return { return {
@@ -70,6 +79,7 @@ export class MoexClient {
tradeAt: new Date(tradeIso).toISOString(), tradeAt: new Date(tradeIso).toISOString(),
}; };
} }
async bondEvents(secid: string): Promise<MoexBondEvent[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
@@ -86,8 +96,9 @@ export class MoexClient {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
const currency = currencyText(row.faceunit); const currency = currencyText(row.faceunit);
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency }); events.push({ type: 'coupon', date, value, currency });
}
} }
for (const row of offers) { for (const row of offers) {
const date = text(row.offerdate); const date = text(row.offerdate);
@@ -99,8 +110,9 @@ export class MoexClient {
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT')); const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency }); events.push({ type: 'maturity', date, value, currency });
}
return events; return events;
} }
@@ -110,15 +122,16 @@ export class MoexClient {
paginated = true, paginated = true,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = []; const rows: Record<string, unknown>[] = [];
let start = 0; let start = FIRST_PAGE_OFFSET;
for (;;) { for (;;) {
const query = new URLSearchParams({ const query = new URLSearchParams({
'iss.meta': 'off', 'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`, 'iss.only': `${block},${block}.cursor`,
start: String(start), 'start': String(start),
}); });
const response = await this.request(`${baseUrl}/${path}?${query}`); const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows; 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]); const current = parseRows(response[block]);
rows.push(...current); rows.push(...current);
if (!paginated) return rows; if (!paginated) return rows;
@@ -134,14 +147,15 @@ export class MoexClient {
const total = integer(cursor?.total); const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize); const pageSize = integer(cursor?.pagesize);
if ( if (
cursors.length !== 1 || cursors.length !== EXPECTED_CURSOR_ROWS ||
index !== start || index !== start ||
total === null || total === null ||
pageSize === null || pageSize === null ||
pageSize === 0 || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start) current.length !== Math.min(pageSize, total - start)
) ) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
if (index + pageSize >= total) return rows; if (index + pageSize >= total) return rows;
start = index + pageSize; start = index + pageSize;
} }
@@ -155,45 +169,51 @@ export class MoexClient {
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object') {
throw new Error('MOEX malformed response'); throw new Error('MOEX malformed response');
}
return body as IssResponse; return body as IssResponse;
} catch { } catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
} }
} }
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] { function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
const columns = block.columns; const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => { 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' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return Object.fromEntries( 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]]), 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; 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); 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; 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(); const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null); return result === 'SUR' ? 'RUB' : (result ?? null);
} }
function integer(value: unknown): number | null { function integer(value: unknown): null | number {
const result = Number(numberText(value) ?? NaN); const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null; return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
} }
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
@Module({ @Module({
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44'; const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => 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 () => { it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest const fetchMock = jest
.spyOn(global, 'fetch') .spyOn(global, 'fetch')
+29 -14
View File
@@ -2,6 +2,13 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { LRUCache } from 'lru-cache'; import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
const REGISTRY_CACHE_TTL_MS = 21_600_000; const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6; const COLUMN_COUNT = 6;
@@ -11,11 +18,11 @@ const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/'; const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = { export type NcrRating = {
agency: 'NCR'; agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string; date: string;
sourceUrl: string;
observedAt: string; observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
}; };
@Injectable() @Injectable()
@@ -33,9 +40,10 @@ export class NcrRatingsClient {
if (!pending) { if (!pending) {
pending = this.registry().catch(() => { pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating. // 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 }); this.cache.set('registry', pending, { ttl: 60_000 });
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
}); });
this.cache.set('registry', pending); this.cache.set('registry', pending);
} }
@@ -54,13 +62,16 @@ export class NcrRatingsClient {
.map((_, cell) => $(cell).text().trim()) .map((_, cell) => $(cell).text().trim())
.get(); .get();
if ( if (
table.length !== 1 || table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' || headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата' headings[DATE_COLUMN] !== 'Дата'
) ) {
throw new Error('NCR registry changed'); throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString(); const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>(); const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => { table.find('tbody tr').each((_, row) => {
@@ -69,27 +80,30 @@ export class NcrRatingsClient {
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument. // Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim(); const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if ( if (
!withdrawn && !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, raw,
) )
) ) {
throw new Error('NCR rating changed'); throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(), cells.eq(DATE_COLUMN).text().trim(),
); );
const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); 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'); throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if ( if (
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
) ) {
throw new Error('NCR date invalid'); throw new Error('NCR date invalid');
}
const rating: NcrRating = { const rating: NcrRating = {
agency: 'NCR', agency: 'NCR',
value: withdrawn ? null : raw, value: withdrawn ? null : raw,
@@ -103,8 +117,9 @@ export class NcrRatingsClient {
previous && previous &&
previous.date === date && previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status) (previous.value !== rating.value || previous.status !== rating.status)
) ) {
throw new Error('NCR rating ambiguous'); throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating); if (!previous || previous.date < date) result.set(isin, rating);
}); });
if (!result.size) throw new Error('NCR registry empty'); if (!result.size) throw new Error('NCR registry empty');
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
@Module({ @Module({
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1'); ).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);
});
});
+4 -2
View File
@@ -1,12 +1,14 @@
import { Decimal } from '../../common/decimal'; import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
const NANO = 1_000_000_000n; 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 { export function decimal(value: ProtoDecimal): Decimal {
return { return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0), value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
scale: 9, scale: 9,
}; };
} }
@@ -1,24 +1,32 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { import type {
Prisma,
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client'; } from '../../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy'; import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
const MISSING_DATA_REVISION = 0;
export type MetadataIdentity = { export type MetadataIdentity = {
instrumentUid?: string | null; figi?: null | string;
figi?: string | null; instrumentUid?: null | string;
}; };
export type MetadataLookup = { export type MetadataLookup = {
type: 'uid' | 'figi'; data: null | TbankInstrument;
id: string; id: string;
data: TbankInstrument | null; type: 'figi' | 'uid';
}; };
const provider = 'tbank'; const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. // Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
@@ -43,8 +51,9 @@ export class InstrumentMetadataRepository {
? [{ identifierType: 'figi', identifier: identity.figi.trim() }] ? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []), : []),
]); ]);
if (!identifiers.length) if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>(); return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = []; const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) { for (const identifierType of ['uid', 'figi']) {
@@ -56,10 +65,10 @@ export class InstrumentMetadataRepository {
), ),
]; ];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < values.length; offset < values.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
lookups.push( lookups.push(
...(await tx.tbankInstrumentLookup.findMany({ ...(await tx.tbankInstrumentLookup.findMany({
where: { where: {
@@ -71,16 +80,17 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
} }
const uids = [ const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])), ...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
]; ];
const instruments: MetadataRow[] = []; const instruments: MetadataRow[] = [];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
instruments.push( instruments.push(
...(await tx.tbankInstrument.findMany({ ...(await tx.tbankInstrument.findMany({
where: { where: {
@@ -89,6 +99,7 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
const byIdentifier = new Map( const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
); );
@@ -103,7 +114,7 @@ export class InstrumentMetadataRepository {
} }
async reserve( async reserve(
attemptOwner: { userId: string; revision: string }, attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity, identity: MetadataIdentity,
runId: string, runId: string,
active: ReadonlySet<string>, active: ReadonlySet<string>,
@@ -125,8 +136,9 @@ export class InstrumentMetadataRepository {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === runId && previous.processRunId === runId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId: runId, processRunId: runId,
@@ -183,12 +195,13 @@ export class InstrumentMetadataRepository {
['uid', uid], ['uid', uid],
['figi', figi], ['figi', figi],
] as const) { ] as const) {
if (id && uid && !aliases.has(`${type}:${id}`)) if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, { aliases.set(`${type}:${id}`, {
type, type,
id, id,
data: { ...result.data, uid }, data: { ...result.data, uid },
}); });
}
} }
} }
for (const result of aliases.values()) { for (const result of aliases.values()) {
@@ -196,8 +209,9 @@ export class InstrumentMetadataRepository {
const uid = const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID. // FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid) if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID'); throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) { if (instrument && uid) {
const key = { provider, uid }; const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({ const previous = await tx.tbankInstrument.findUnique({
@@ -221,8 +235,8 @@ export class InstrumentMetadataRepository {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) increment: isDeepStrictEqual(previous?.metadata, payload)
? 0 ? UNCHANGED_REVISION_INCREMENT
: 1, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -244,8 +258,8 @@ export class InstrumentMetadataRepository {
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? 0 ? UNCHANGED_REVISION_INCREMENT
: 1, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -263,18 +277,19 @@ export class InstrumentMetadataRepository {
}, },
data: { data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE', errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
}); });
if (success) if (success) {
await this.prisma.tbankSyncWork.updateMany({ await this.prisma.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
state: 'running', state: 'running',
connection: { invalidToken: true }, connection: { invalidToken: true },
}, },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
}
} }
} }
@@ -283,7 +298,7 @@ function selectMetadata(
lookups: ReadonlyMap<string, MetadataLookupRow>, lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>, instruments: ReadonlyMap<string, MetadataRow>,
) { ) {
const resolve = (type: string, id: string | null | undefined) => { const resolve = (type: string, id: null | string | undefined) => {
const lookup = lookups.get(`${type}:${id?.trim()}`); const lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined; const instrument = uid ? instruments.get(uid) : undefined;
@@ -307,13 +322,14 @@ function selectMetadata(
complete, complete,
fresh: fresh:
complete && complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null && fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
(row) => (row) =>
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, `${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
) )
.join('/'), .join('/'),
}; };
@@ -1,23 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import { import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client'; 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 { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository';
type Producer = { type Producer = {
identity: MetadataIdentity;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
identity: MetadataIdentity;
observers: TbankSyncAttempt[]; observers: TbankSyncAttempt[];
}; };
@Injectable() @Injectable()
@@ -49,8 +51,9 @@ export class InstrumentMetadataService {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
if (revision && connection.revision !== revision) return null; if (revision && connection.revision !== revision) return null;
const data = (await this.savedMany([identity])).get(metadataKey(identity)); const data = (await this.savedMany([identity])).get(metadataKey(identity));
if (!observe && !data?.fresh) if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity); await this.request(userId, connection.revision, identity);
}
return { return {
...data, ...data,
work: await this.repository.work(userId, connection.revision, identity), work: await this.repository.work(userId, connection.revision, identity),
@@ -119,7 +122,7 @@ export class InstrumentMetadataService {
let useful = false; let useful = false;
let uidFailed = false; let uidFailed = false;
if (identity.instrumentUid) { if (identity.instrumentUid) {
let data: TbankInstrument | null | undefined; let data: null | TbankInstrument | undefined;
try { try {
data = await this.tbank.getInstrumentByUid( data = await this.tbank.getInstrumentByUid(
token, token,
@@ -129,30 +132,38 @@ export class InstrumentMetadataService {
if ( if (
!identity.figi || !identity.figi ||
(error instanceof Error && (error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN') isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) ) {
throw error; throw error;
}
uidFailed = true; uidFailed = true;
} }
if (data?.uid && data.uid !== identity.instrumentUid) if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch'); throw new Error('Metadata UID mismatch');
if (data !== undefined) }
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi) if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
} }
if (!useful && identity.figi) { if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi( const data = await this.tbank.getInstrumentByFigi(
token, token,
identity.figi, identity.figi,
); );
if (data?.figi && data.figi !== identity.figi) if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch'); 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'); 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'); throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data }); results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too; // A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup. // a failed UID RPC is never persisted as a negative lookup.
@@ -160,8 +171,9 @@ export class InstrumentMetadataService {
uidFailed && uidFailed &&
data?.uid === identity.instrumentUid && data?.uid === identity.instrumentUid &&
identity.instrumentUid identity.instrumentUid
) ) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
} }
if ( if (
!results.length || !results.length ||
@@ -170,8 +182,9 @@ export class InstrumentMetadataService {
(result) => (result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(), result.data?.name?.trim() || result.data?.ticker?.trim(),
)) ))
) ) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
results, results,
@@ -179,12 +192,13 @@ export class InstrumentMetadataService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
else } else {
this.logger.event('warn', 'instrument-metadata.failed', { this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
}
} }
// Remove the producer before awaiting finalization: a new request cannot // Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained. // attach to a producer whose observer list has already been drained.
@@ -196,7 +210,7 @@ export class InstrumentMetadataService {
} }
} catch { } catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', { this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
this.active.clear(); this.active.clear();
this.producers.clear(); this.producers.clear();
+36 -21
View File
@@ -1,4 +1,3 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -6,16 +5,24 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; 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 type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1;
const MEMBERSHIP_REVISION_INCREMENT = 1;
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
export type TbankSyncAttempt = { export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string; attemptId: string;
id: string;
revision: string;
userId: string;
}; };
@Injectable() @Injectable()
@@ -48,8 +55,9 @@ export class TbankAccessService {
connection.membershipAt, connection.membershipAt,
new Date(), new Date(),
) )
) ) {
return null; return null;
}
const scope = { userId, revision, scopeKey: 'membership' }; const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
@@ -58,8 +66,9 @@ export class TbankAccessService {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId, processRunId,
@@ -95,12 +104,12 @@ export class TbankAccessService {
state: 'running', state: 'running',
connection: { invalidToken: false }, connection: { invalidToken: false },
}, },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
}); });
return result.count === 1; return result.count === EXPECTED_UPDATED_CONNECTIONS;
} }
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> { async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
const work = await this.prisma.tbankSyncWork.findFirst({ const work = await this.prisma.tbankSyncWork.findFirst({
where: { where: {
...attempt, ...attempt,
@@ -119,8 +128,9 @@ export class TbankAccessService {
completeWork = false, completeWork = false,
): Promise<boolean> { ): Promise<boolean> {
const supported = supportedTbankAccounts(accounts); 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'); throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and // Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below. // attempt supersession cannot interleave with the publication below.
@@ -146,6 +156,7 @@ export class TbankAccessService {
const changed = const changed =
previous.length !== next.length || previous.length !== next.length ||
previous.some((account, index) => { 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]; const incoming = next[index];
return ( return (
account.accountId !== incoming.accountId || account.accountId !== incoming.accountId ||
@@ -178,14 +189,15 @@ export class TbankAccessService {
where: { userId_revision: { userId, revision } }, where: { userId_revision: { userId, revision } },
data: { data: {
membershipAt: fetchedAt, membershipAt: fetchedAt,
membershipRevision: { increment: changed ? 1 : 0 }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
if (completeWork) if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } }, data: { state: 'complete', progress: { accounts: supported.length } },
}); });
}
return true; return true;
}); });
} }
@@ -194,7 +206,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' }, where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
if (!claimed.count) return false; if (!claimed.count) return false;
await tx.tbankConnection.update({ await tx.tbankConnection.update({
@@ -224,17 +236,20 @@ export class TbankAccessService {
accounts: { orderBy: { upstreamOrder: 'asc' } }, accounts: { orderBy: { upstreamOrder: 'asc' } },
}, },
}); });
if (!connection) if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) }
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if ( if (
connection.membershipAt && connection.membershipAt &&
accountId && accountId &&
accountId !== 'all' && accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId) !connection.accounts.some((account) => account.accountId === accountId)
) ) {
throw new NotFoundException(); throw new NotFoundException();
}
return connection; return connection;
} }
} }
@@ -1,14 +1,15 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccountType } from './tbank-protocol';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: { [TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT', type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС', name: 'ИИС',
}, },
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: { [TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND', type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка', name: 'Фонд денежного рынка',
}, },
@@ -18,8 +19,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = { export type SupportedTbankAccount = {
id: string; id: string;
name: string; name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type']; type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
}; };
export function supportedTbankAccounts( export function supportedTbankAccounts(
@@ -39,9 +40,9 @@ export function supportedTbankAccounts(
}); });
} }
function openedOn(seconds: string | number | undefined): string | null { function openedOn(seconds: number | string | undefined): null | string {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10); return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
@@ -0,0 +1,10 @@
export enum TbankErrorCode {
InvalidToken = 'INVALID_TBANK_TOKEN',
NotConnected = 'TBANK_NOT_CONNECTED',
Unavailable = 'TBANK_UNAVAILABLE',
}
// Error messages and persisted codes cross an open string boundary.
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
return value === expected;
}
+23 -23
View File
@@ -6,23 +6,23 @@ import type {
} from './tbank.client'; } from './tbank.client';
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
uid?: string; apiTradeAvailableFlag?: boolean;
figi?: string;
assetUid?: string; assetUid?: string;
classCode?: string; classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean;
currency?: string; currency?: string;
shareType?: string; figi?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
}; };
export type TbankCandle = { export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal; close?: TbankDecimal;
isComplete?: boolean; isComplete?: boolean;
time?: TbankTimestamp;
}; };
export type TbankLastPrice = { export type TbankLastPrice = {
instrumentUid?: string; instrumentUid?: string;
@@ -32,38 +32,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = { export type TbankFundamentals = {
assetUid?: string; assetUid?: string;
currency?: string; currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number; marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number; peRatioTtm?: number;
priceToBookTtm?: number; priceToBookTtm?: number;
revenueTtm?: number; revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number; roe?: number;
totalDebtMrq?: number; totalDebtMrq?: number;
netDebtToEbitda?: number;
}; };
export type TbankBond = Omit<TbankShare, 'shareType'> & { export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney; aciValue?: TbankMoney;
maturityDate?: TbankTimestamp; amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp; callDate?: TbankTimestamp;
couponQuantityPerYear?: number; couponQuantityPerYear?: number;
floatingCouponFlag?: boolean; floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean; perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string; riskLevel?: string;
subordinatedFlag?: boolean;
}; };
export type TbankCoupon = { export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp; couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp; fixDate?: TbankTimestamp;
payOneBond?: TbankMoney; payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
}; };
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@Injectable() @Injectable()
@@ -36,7 +38,7 @@ export class TbankMembershipService {
.catch(() => .catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', { this.logger.event('error', 'tbank.membership.persistence_failed', {
userId, userId,
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}), }),
) )
.finally(() => this.active.delete(attemptId)); .finally(() => this.active.delete(attemptId));
@@ -61,7 +63,7 @@ export class TbankMembershipService {
} catch (error) { } catch (error) {
await this.access.failMembership( await this.access.failMembership(
attempt, attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
); );
} }
} }
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+70
View File
@@ -0,0 +1,70 @@
// String wire values for the protocol subset used by this integration.
// proto-loader decodes enums as strings; vendor contracts remain the source.
export enum InstrumentIdType {
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
Uid = 'INSTRUMENT_ID_TYPE_UID',
}
export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE',
}
export enum TbankAccountStatus {
Open = 'ACCOUNT_STATUS_OPEN',
}
export enum TbankAccountType {
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
}
export enum TbankCandleInterval {
Day = 'CANDLE_INTERVAL_DAY',
}
export enum TbankOperationState {
Executed = 'OPERATION_STATE_EXECUTED',
}
export enum TbankCouponType {
Floating = 'COUPON_TYPE_FLOATING',
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
}
export enum TbankOperationType {
BondTax = 'OPERATION_TYPE_BOND_TAX',
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
Buy = 'OPERATION_TYPE_BUY',
BuyCard = 'OPERATION_TYPE_BUY_CARD',
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
Coupon = 'OPERATION_TYPE_COUPON',
DivExt = 'OPERATION_TYPE_DIV_EXT',
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
Input = 'OPERATION_TYPE_INPUT',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
Output = 'OPERATION_TYPE_OUTPUT',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
Tax = 'OPERATION_TYPE_TAX',
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
}
// Provider responses may contain values newer than our supported protocol subset.
export function isTbankProtocolValue(
value: unknown,
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
): boolean {
return value === expected;
}
@@ -8,6 +8,7 @@ describe('request-driven source freshness', () => {
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
const fetchedAt = new Date('2026-09-01Z'); 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]; const ttl = tbankSourceTtlMs[source];
expect( expect(
tbankSourceNeedsRefresh( tbankSourceNeedsRefresh(
@@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh(
return ( return (
force || force ||
!fetchedAt || !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] now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
); );
} }
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
@@ -25,8 +27,9 @@ export class TbankTokenService {
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error; throw error;
} }
} }
+11 -11
View File
@@ -1,9 +1,12 @@
// Public cross-module surface. Nest providers are exported by the matching module. // Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service'; export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
@@ -12,8 +15,15 @@ export {
type TbankLastPrice, type TbankLastPrice,
type TbankShare, type TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service'; export { TbankTokenService } from './tbank-token.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
TbankClient, TbankClient,
type TbankDividend, type TbankDividend,
@@ -25,13 +35,3 @@ export {
type TbankPositions, type TbankPositions,
type TbankTimestamp, type TbankTimestamp,
} from './tbank.client'; } 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';
+18 -19
View File
@@ -1,5 +1,7 @@
import { status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -9,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' }, to: { seconds: '2' },
cursor: 'cursor', cursor: 'cursor',
limit: 10, limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const, state: TbankOperationState.Executed,
}; };
it('passes a cursor operations request to OperationsService', async () => { it('passes a cursor operations request to OperationsService', async () => {
@@ -57,6 +59,7 @@ describe('TbankClient operations', () => {
const client = Object.create(TbankClient.prototype) as TbankClient; const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({}); const call = jest.fn().mockResolvedValue({});
(client as unknown as { call: jest.Mock }).call = call; (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'); await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
expect(call).toHaveBeenCalledWith('token', undefined, method, { expect(call).toHaveBeenCalledWith('token', undefined, method, {
instrumentId: 'uid', instrumentId: 'uid',
@@ -111,11 +114,10 @@ describe('TbankClient operations', () => {
ok: true, ok: true,
}); });
const failed = invoke('token', {}, 'getPortfolio', {}); const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual( await Promise.all([
expect.any(ServiceUnavailableException), expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
); jest.advanceTimersByTimeAsync(1000),
await jest.advanceTimersByTimeAsync(1000); ]);
await rejected;
expect(logger.event).toHaveBeenCalledWith( expect(logger.event).toHaveBeenCalledWith(
'debug', 'debug',
@@ -140,7 +142,7 @@ describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' }; const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient; let client: TbankClient;
let requestRpc: jest.Mock; let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock }; let queue: { run: jest.Mock; size: number };
let logger: { let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>; event: jest.Mock<void, [string, string, Record<string, unknown>]>;
}; };
@@ -234,12 +236,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying a saturated queue after three admission attempts', async () => { it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50; queue.size = 50;
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(6000),
]);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3); expect(logger.event).toHaveBeenCalledTimes(3);
@@ -253,12 +253,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying an unavailable upstream after three RPCs', async () => { it('stops retrying an unavailable upstream after three RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(6000),
]);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
@@ -282,6 +280,7 @@ describe('TbankClient metadata retries', () => {
async (method) => { async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); 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(); await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
+95 -71
View File
@@ -1,4 +1,3 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
@@ -17,8 +16,6 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type { import type {
TbankBond, TbankBond,
@@ -29,6 +26,25 @@ import type {
TbankShare, TbankShare,
} from './tbank-market.types'; } 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';
import { TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
TbankAccountStatus,
TbankCandleInterval,
TbankInstrumentType,
TbankOperationState,
} from './tbank-protocol';
const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1;
const FIRST_ATTEMPT = 1;
const ATTEMPT_INCREMENT = 1;
const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3; const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
@@ -42,78 +58,78 @@ type Unary<Request, Response> = (
request: Request, request: Request,
metadata: Metadata, metadata: Metadata,
options: { deadline: Date }, options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; type TbankClientService = Client & Record<string, Unary<never, never>>;
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 TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
id: string; id: string;
type: string;
name: string; name: string;
openedDate?: { seconds?: string | number }; openedDate?: { seconds?: number | string };
type: string;
}; };
export type TbankPortfolio = { export type TbankPortfolio = {
positions?: { positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney; averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[]; }[];
}; };
export type TbankPositions = { money?: TbankMoney[] }; export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = { export type TbankInstrument = {
uid?: string; brand?: { logoName?: null | string };
figi?: string; figi?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string;
name?: string;
isin?: string; isin?: string;
brand?: { logoName?: string | null }; name?: string;
ticker?: string;
uid?: string;
}; };
export type TbankDividend = { export type TbankDividend = {
dividendNet?: TbankMoney; dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string; 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 = { export type TbankOperationItem = {
state?: string; accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string; cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp; date?: TbankTimestamp;
type?: string;
description?: string; description?: string;
figi?: string; figi?: string;
instrumentUid?: string; id?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string; instrumentUid?: string;
name?: string;
payment?: TbankMoney; payment?: TbankMoney;
price?: TbankMoney; price?: TbankMoney;
commission?: TbankMoney; quantity?: number | string;
accruedInt?: TbankMoney; quantityDone?: number | string;
quantity?: string | number; state?: string;
quantityDone?: string | number; ticker?: string;
type?: string;
}; };
export type TbankOperationsByCursorRequest = { export type TbankOperationsByCursorRequest = {
accountId: string; accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string; cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number; limit?: number;
state: 'OPERATION_STATE_EXECUTED'; state: TbankOperationState.Executed;
to?: TbankTimestamp;
}; };
export type TbankOperationsByCursorResponse = { export type TbankOperationsByCursorResponse = {
hasNext?: boolean; hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[]; items?: TbankOperationItem[];
nextCursor?: string;
}; };
type TbankNamespace = { type TbankNamespace = {
tinkoff: { tinkoff: {
@@ -123,7 +139,7 @@ type TbankNamespace = {
class RateLimiter { class RateLimiter {
private readonly waiting: (() => void)[] = []; private readonly waiting: (() => void)[] = [];
private starts = 0; private starts = INITIAL_REQUEST_COUNT;
private windowStartedAt = Date.now(); private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {} constructor(private readonly perSecond: number) {}
@@ -148,16 +164,16 @@ class RateLimiter {
const now = Date.now(); const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) { if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now; this.windowStartedAt = now;
this.starts = 0; this.starts = INITIAL_REQUEST_COUNT;
} }
while (this.starts < this.perSecond && this.waiting.length) { while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1; this.starts++;
this.waiting.shift()?.(); this.waiting.shift()?.();
} }
if (this.waiting.length && this.starts === this.perSecond) { if (this.waiting.length && this.starts === this.perSecond) {
setTimeout( setTimeout(
() => this.drain(), () => this.drain(),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)), Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
); );
} }
} }
@@ -198,11 +214,13 @@ export class TbankClient {
).tinkoff.public.invest.api.contract.v1; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath const credentials = caPath
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
? ChannelCredentials.createSsl(readFileSync(caPath)) ? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl(); : ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new ( services[name] as new (
address: string, address: string,
credentials: ChannelCredentials, credentials: ChannelCredentials,
@@ -246,7 +264,7 @@ export class TbankClient {
token, token,
this.users, this.users,
'getAccounts', 'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' }, { status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []); ).then((response) => response.accounts ?? []);
} }
@@ -268,15 +286,15 @@ export class TbankClient {
getInstrumentByFigi( getInstrumentByFigi(
token: string, token: string,
figi: string, figi: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); return this.getInstrument(token, InstrumentIdType.Figi, figi);
} }
getInstrumentByUid( getInstrumentByUid(
token: string, token: string,
instrumentUid: string, instrumentUid: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
} }
getDividends( getDividends(
@@ -304,7 +322,7 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE', instrumentKind: TbankInstrumentType.Share,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
@@ -316,21 +334,23 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_BOND', instrumentKind: TbankInstrumentType.Bond,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
getBond(token: string, uid: string): Promise<TbankBond | null> {
getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -349,19 +369,19 @@ export class TbankClient {
).then((response) => response.events ?? []); ).then((response) => response.events ?? []);
} }
getShare(token: string, uid: string): Promise<TbankShare | null> { getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetDescription(token: string, assetUid: string): Promise<string | null> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
this.instruments, this.instruments,
@@ -375,7 +395,7 @@ export class TbankClient {
getFundamentals( getFundamentals(
token: string, token: string,
assetUid: string, assetUid: string,
): Promise<TbankFundamentals | null> { ): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>( return this.call<{ fundamentals?: TbankFundamentals[] }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
@@ -399,13 +419,13 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).then((response) => response.lastPrices ?? []);
} }
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> { getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then( return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null, (prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
); );
} }
getTradingStatus(token: string, uid: string): Promise<string | null> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
this.marketData, this.marketData,
@@ -434,7 +454,7 @@ export class TbankClient {
to: { to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
}, },
interval: 'CANDLE_INTERVAL_DAY', interval: TbankCandleInterval.Day,
limit: 2400, limit: 2400,
}, },
).then((response) => response.candles ?? []); ).then((response) => response.candles ?? []);
@@ -442,9 +462,9 @@ export class TbankClient {
private async getInstrument( private async getInstrument(
token: string, token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', idType: InstrumentIdType,
id: string, id: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
try { try {
const response = await this.call<{ instrument?: TbankInstrument }>( const response = await this.call<{ instrument?: TbankInstrument }>(
token, token,
@@ -467,12 +487,13 @@ export class TbankClient {
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) { for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
if (queueFull) if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
const response = await this.queue.run(() => const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request), this.request<Response>(token, client, method, request),
); );
@@ -485,14 +506,14 @@ export class TbankClient {
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' } ? { code: TbankErrorCode.Unavailable }
: { grpcCode }; : { grpcCode };
const retryable = queueFull const retryable = queueFull
? metadataCall ? metadataCall
: [ : [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode); ].includes(grpcCode);
if (retryable && attempts < maxAttempts) { if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
@@ -514,23 +535,25 @@ export class TbankClient {
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts, attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( 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. // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN'); throw new Error(TbankErrorCode.InvalidToken);
}
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall) (method === 'shareBy' || metadataCall)
) ) {
throw new NotFoundException(); throw new NotFoundException();
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
} }
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
private request<Response>( private request<Response>(
@@ -542,6 +565,7 @@ export class TbankClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); 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]( client[method](
request as never, request as never,
metadata, metadata,
@@ -555,6 +579,6 @@ export class TbankClient {
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)). // Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
function utcSeconds(date: string, nextDay = false): number { function utcSeconds(date: string, nextDay = false): number {
const instant = new Date(`${date}T00:00:00Z`); const instant = new Date(`${date}T00:00:00Z`);
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1); if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND); return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
} }
+7 -4
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const VERSION_SIZE_BYTES = 1;
const KEY_SIZE_BYTES = 32; const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
@@ -11,8 +13,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); 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'); throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
@@ -33,15 +36,15 @@ export class TbankCrypto {
decrypt(payload: string): string { decrypt(payload: string): string {
const bytes = Buffer.from(payload, 'base64'); const bytes = Buffer.from(payload, 'base64');
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) { if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
throw new Error('Invalid encrypted token'); throw new Error('Invalid encrypted token');
} }
const nonce = bytes.subarray(1, 1 + NONCE_SIZE); const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
const tag = bytes.subarray(-TAG_SIZE); const tag = bytes.subarray(-TAG_SIZE);
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([ return Buffer.concat([
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)), decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
decipher.final(), decipher.final(),
]).toString('utf8'); ]).toString('utf8');
} }
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankMembershipService } from './tbank-membership.service';
import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service'; import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
+5 -3
View File
@@ -3,18 +3,19 @@ import {
INestApplication, INestApplication,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger'; import { SwaggerModule } from '@nestjs/swagger';
import { API_PREFIX, createOpenApiDocument } from './openapi'; import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware'; import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service'; import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
import { API_PREFIX, createOpenApiDocument } from './openapi';
export function configureApp(app: INestApplication): void { export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService); const config = app.get<ConfigService<Environment, true>>(ConfigService);
@@ -32,6 +33,7 @@ export function configureApp(app: INestApplication): void {
app.use(requestLogging.use.bind(requestLogging)); app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidUnknownValues: true,
transform: true, transform: true,
whitelist: true, whitelist: true,
validationError: { target: false, value: false }, validationError: { target: false, value: false },
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
@@ -114,8 +114,8 @@ describe('AuthService', () => {
where, where,
data, data,
}: { }: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string }; data: { refreshTokenHash: string };
where: { id?: string; refreshTokenHash?: string };
}) => { }) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) { if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash; currentHash = data.refreshTokenHash;
+36 -22
View File
@@ -6,16 +6,23 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthErrorCode } from './auth-error-code';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000; const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = 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'; '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable() @Injectable()
@@ -28,12 +35,13 @@ export class AuthService {
async register( async register(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
if (existing) if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
let user: User; let user: User;
try { try {
@@ -45,8 +53,9 @@ export class AuthService {
}, },
}); });
} catch (error) { } catch (error) {
if (isUniqueConstraintError(error)) if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error; throw error;
} }
return this.createSession(user); return this.createSession(user);
@@ -55,7 +64,7 @@ export class AuthService {
async login( async login(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
@@ -63,24 +72,26 @@ export class AuthService {
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
); );
if (!user || !valid) if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user); return this.createSession(user);
} }
async refresh( async refresh(
token: string, token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
let payload: RefreshPayload; let payload: RefreshPayload;
try { try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
} catch { } catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: payload.sid }, where: { id: payload.sid },
include: { user: true }, include: { user: true },
@@ -91,12 +102,14 @@ export class AuthService {
session.revokedAt || session.revokedAt ||
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (session.refreshTokenHash !== this.hash(token))
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.rotateSession(session, session.refreshTokenHash); return this.rotateSession(session, session.refreshTokenHash);
} }
@@ -132,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
private async createSession( private async createSession(
user: User, user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const session = await this.prisma.session.create({ const session = await this.prisma.session.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -153,7 +166,7 @@ export class AuthService {
private async rotateSession( private async rotateSession(
session: Session & { user: User }, session: Session & { user: User },
expectedRefreshTokenHash?: string, expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const refreshToken = await this.jwt.signAsync( const refreshToken = await this.jwt.signAsync(
{ {
sub: session.userId, sub: session.userId,
@@ -172,8 +185,9 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data, data,
}); });
if (updated.count !== 1) if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else { } else {
await this.prisma.session.update({ where: { id: session.id }, data }); await this.prisma.session.update({ where: { id: session.id }, data });
} }
@@ -197,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' && typeof error === 'object' &&
error !== null && error !== null &&
'code' in error && 'code' in error &&
(error as { code?: unknown }).code === 'P2002' isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
); );
} }
+5 -5
View File
@@ -1,17 +1,17 @@
export interface AuthUser { export interface AuthUser {
id: string;
email: string; email: string;
id: string;
} }
export interface AccessPayload { export interface AccessPayload {
sub: string;
sid: string; sid: string;
sub: string;
type: 'access'; type: 'access';
} }
export interface RefreshPayload { export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string; jti: string;
sid: string;
sub: string;
type: 'refresh';
} }
+3 -2
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@Module({ @Module({
imports: [ imports: [
@@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -17,8 +19,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
validate(payload: AccessPayload): AccessPayload { validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return payload; return payload;
} }
} }
@@ -1,3 +1,5 @@
import type { Request, Response } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -17,11 +19,12 @@ import {
ApiProperty, ApiProperty,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto'; import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -84,7 +87,7 @@ export class AuthController {
) { ) {
try { try {
const token = this.refreshToken(request); const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return this.respond(response, await this.auth.refresh(token)); return this.respond(response, await this.auth.refresh(token));
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response); if (error instanceof UnauthorizedException) this.clearCookie(response);
@@ -115,9 +118,9 @@ export class AuthController {
private respond( private respond(
response: Response, response: Response,
result: { result: {
user: { id: string; email: string };
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
user: { email: string; id: string };
}, },
) { ) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
@@ -129,6 +132,7 @@ export class AuthController {
} }
private refreshToken(request: Request): string | undefined { 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]; const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined; return typeof token === 'string' ? token : undefined;
} }
+9 -4
View File
@@ -7,6 +7,9 @@ import {
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
@@ -19,18 +22,19 @@ class EmailConstraint {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === EMAIL_PART_COUNT &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE, character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must be a valid email address'; return 'must be a valid email address';
} }
@@ -44,6 +48,7 @@ class PasswordBytesConstraint {
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return 'must not exceed 72 UTF-8 bytes';
} }
@@ -1,5 +1,6 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service'; import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
const request = context const request = context
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.requestContext.set({ userId: request.user.sub });
}
return Boolean(allowed); return Boolean(allowed);
} }
} }
@@ -1,40 +1,44 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
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 { import {
bankPaymentSystems, bankPaymentSystems,
BankPeriodQueryDto, BankPeriodQueryDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = { const NEXT_MONTH_OFFSET = 1;
InvalidInput: 'BANK_INVALID_INPUT', const PREVIOUS_MONTH_LAST_DAY = 0;
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND', export enum BankErrorCode {
InvalidMoney: 'BANK_INVALID_MONEY', CardArchived = 'BANK_CARD_ARCHIVED',
InvalidDate: 'BANK_INVALID_DATE', DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
InvalidRefund: 'BANK_INVALID_REFUND', ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
RefundLimit: 'BANK_REFUND_LIMIT', IntentConflict = 'BANK_INTENT_CONFLICT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS', InvalidDate = 'BANK_INVALID_DATE',
IntentConflict: 'BANK_INTENT_CONFLICT', InvalidInput = 'BANK_INVALID_INPUT',
OperationDeleted: 'BANK_OPERATION_DELETED', InvalidMoney = 'BANK_INVALID_MONEY',
CardArchived: 'BANK_CARD_ARCHIVED', InvalidRefund = 'BANK_INVALID_REFUND',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', NotFound = 'BANK_NOT_FOUND',
WriteConflict: 'BANK_WRITE_CONFLICT', OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', OperationDeleted = 'BANK_OPERATION_DELETED',
} as const; RefundLimit = 'BANK_REFUND_LIMIT',
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; VersionConflict = 'BANK_VERSION_CONFLICT',
WriteConflict = 'BANK_WRITE_CONFLICT',
}
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
@@ -44,8 +48,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
const from = `${query.month}-01`; const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail(); if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`); const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0); end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
return { from, to: end.toISOString().slice(0, 10) }; return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
} }
if ( if (
query.month || query.month ||
@@ -54,8 +58,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
!isCalendarDate(query.from) || !isCalendarDate(query.from) ||
!isCalendarDate(query.to) || !isCalendarDate(query.to) ||
query.from > query.to query.from > query.to
) ) {
return fail(); return fail();
}
return { from: query.from, to: query.to }; return { from: query.from, to: query.to };
} }
export function conflict( export function conflict(
@@ -93,8 +98,9 @@ export function currentDate(value: string) {
if (value > moscowToday()) fail(BankErrorCode.InvalidDate); if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
} }
export function name(value: string, max: number) { 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(); fail();
}
return value.trim(); return value.trim();
} }
export function cardInput(dto: CreateBankCardDto) { export function cardInput(dto: CreateBankCardDto) {
@@ -104,8 +110,9 @@ export function cardInput(dto: CreateBankCardDto) {
if ( if (
dto.paymentSystem != null && dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem) !bankPaymentSystems.some((system) => system === dto.paymentSystem)
) ) {
fail(); fail();
}
return { return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, MAX_CARD_NAME_LENGTH), name: name(dto.name, MAX_CARD_NAME_LENGTH),
@@ -120,8 +127,9 @@ export function operationInput(dto: CreateBankOperationDto) {
dto.description != null && dto.description != null &&
(typeof dto.description !== 'string' || (typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
) ) {
fail(); fail();
}
return { return {
kind: dto.kind, kind: dto.kind,
cardId: dto.cardId, cardId: dto.cardId,
@@ -1,24 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; 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 { import {
BankCardDto, BankCardDto,
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { import {
cardInput,
BankErrorCode, BankErrorCode,
cardInput,
conflict, conflict,
currentDate, currentDate,
fail, fail,
fingerprint, fingerprint,
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
@@ -26,6 +29,7 @@ export class BankCardsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where: { where: {
@@ -47,6 +51,7 @@ export class BankCardsService {
archivedAt: card.archivedAt?.toISOString() ?? null, archivedAt: card.archivedAt?.toISOString() ?? null,
}; };
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
Promise.all( Promise.all(
@@ -59,11 +64,13 @@ export class BankCardsService {
), ),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
createCard(userId: string, dto: CreateBankCardDto) { createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
@@ -71,8 +78,10 @@ export class BankCardsService {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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); conflict(BankErrorCode.IntentConflict);
}
return this.cardView(tx, existing); return this.cardView(tx, existing);
} }
currentDate(input.openingDate); currentDate(input.openingDate);
@@ -85,6 +94,7 @@ export class BankCardsService {
); );
}); });
} }
updateCard(userId: string, id: string, dto: UpdateBankCardDto) { updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id); const previous = await this.repository.card(tx, userId, id);
@@ -111,8 +121,9 @@ export class BankCardsService {
const financial = const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate; input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) if (financial && previous.archivedAt) {
conflict(BankErrorCode.CardArchived); conflict(BankErrorCode.CardArchived);
}
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await tx.bankCardOperation.findFirst({
@@ -123,8 +134,9 @@ export class BankCardsService {
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
}, },
}) })
) ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
}
const changed = await tx.bankCard.updateMany({ const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version }, where: { id, userId, version: dto.version },
data: { data: {
@@ -140,7 +152,7 @@ export class BankCardsService {
}), }),
}, },
}); });
if (changed.count !== 1) conflict(); if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id)); return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
@@ -1,20 +1,22 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import { import {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
BankOperationDto, BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto, BankOperationsQueryDto,
BankPageQueryDto, BankPageQueryDto,
CreateBankOperationDto, CreateBankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
UpdateBankOperationDto, UpdateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
@@ -25,7 +27,9 @@ import {
operationInput, operationInput,
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
@@ -33,6 +37,7 @@ export class BankOperationsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
row: BankCardOperation, row: BankCardOperation,
@@ -76,7 +81,7 @@ export class BankOperationsService {
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source
? rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) )
: null, : null,
date: row.date, date: row.date,
@@ -85,6 +90,7 @@ export class BankOperationsService {
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
private async validateOperation( private async validateOperation(
tx: Tx, tx: Tx,
userId: string, userId: string,
@@ -102,23 +108,26 @@ export class BankOperationsService {
if ( if (
(id === input.cardId || id === input.targetCardId) && (id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate input.date < card.openingDate
) ) {
fail(BankErrorCode.InvalidDate); fail(BankErrorCode.InvalidDate);
}
} }
if ( if (
old && old &&
(old.kind !== input.kind || (old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId) old.originalExpenseId !== input.originalExpenseId)
) ) {
fail(BankErrorCode.ImmutableOperation); fail(BankErrorCode.ImmutableOperation);
}
if (input.kind === BankOperationKind.Transfer) { if (input.kind === BankOperationKind.Transfer) {
if ( if (
!input.targetCardId || !input.targetCardId ||
input.targetCardId === input.cardId || input.targetCardId === input.cardId ||
input.categoryId || input.categoryId ||
input.originalExpenseId input.originalExpenseId
) ) {
fail(); fail();
}
} else if (input.targetCardId) fail(); } else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail(); if (input.originalExpenseId) fail();
@@ -133,11 +142,13 @@ export class BankOperationsService {
!(await tx.expenseCategory.findFirst({ !(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId }, where: { userId, id: input.categoryId },
})) }))
) ) {
missing(); missing();
}
} else if (input.categoryId) fail(); } else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail(); fail();
}
if (input.kind === BankOperationKind.Refund) { if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail(); if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
@@ -149,8 +160,9 @@ export class BankOperationsService {
source.deletedAt || source.deletedAt ||
source.kind !== BankOperationKind.Expense || source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId source.cardId !== input.cardId
) ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
}
if ( if (
( (
await tx.bankCardOperation.updateMany({ await tx.bankCardOperation.updateMany({
@@ -162,9 +174,10 @@ export class BankOperationsService {
}, },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
where: { where: {
userId, userId,
@@ -175,11 +188,12 @@ export class BankOperationsService {
}); });
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
input.amountKopecks > input.amountKopecks >
source.amountKopecks source.amountKopecks
) ) {
conflict(BankErrorCode.RefundLimit); conflict(BankErrorCode.RefundLimit);
}
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
@@ -189,12 +203,14 @@ export class BankOperationsService {
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) || refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
input.amountKopecks) input.amountKopecks)
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
} }
} }
createOperation(userId: string, dto: CreateBankOperationDto) { createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
@@ -204,10 +220,13 @@ export class BankOperationsService {
}); });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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); conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt) }
if (existing.deletedAt) {
throw new GoneException({ code: BankErrorCode.OperationDeleted }); throw new GoneException({ code: BankErrorCode.OperationDeleted });
}
return this.operationView(tx, existing); return this.operationView(tx, existing);
} }
const input = { ...normalized }; const input = { ...normalized };
@@ -220,6 +239,7 @@ export class BankOperationsService {
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
@@ -227,6 +247,7 @@ export class BankOperationsService {
return this.operationView(tx, row); return this.operationView(tx, row);
}); });
} }
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const old = await this.repository.operation(tx, userId, id); const old = await this.repository.operation(tx, userId, id);
@@ -251,30 +272,34 @@ export class BankOperationsService {
where: { id, userId, version: dto.version, deletedAt: null }, where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } }, data: { ...input, version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
); );
}); });
} }
deleteOperation(userId: string, id: string, version: number) { deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return; if (row.deletedAt) return;
if (row.version !== version) conflict(); if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true); await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId) if (row.targetCardId) {
await this.repository.card(tx, userId, row.targetCardId, true); await this.repository.card(tx, userId, row.targetCardId, true);
}
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null }, where: { userId, originalExpenseId: id, deletedAt: null },
})) }))
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
if (row.originalExpenseId) { if (row.originalExpenseId) {
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
@@ -287,9 +312,10 @@ export class BankOperationsService {
where: { id: source.id, userId, version: source.version }, where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
} }
if ( if (
( (
@@ -297,11 +323,13 @@ export class BankOperationsService {
where: { id, userId, version, deletedAt: null }, where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } }, data: { deletedAt: new Date(), version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
}); });
} }
history( history(
userId: string, userId: string,
id: string, id: string,
@@ -313,8 +341,8 @@ export class BankOperationsService {
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const page = query.page ?? 1, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const where: Prisma.BankCardOperationWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
@@ -327,7 +355,7 @@ export class BankOperationsService {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where, where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit, skip: (page - FIRST_PAGE) * limit,
take: limit, take: limit,
}); });
return { return {
@@ -1,16 +1,21 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; 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 { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
async categories(userId: string) { async categories(userId: string) {
return ( return (
await this.prisma.expenseCategory.findMany({ await this.prisma.expenseCategory.findMany({
@@ -24,6 +29,7 @@ export class ExpenseCategoriesService {
version, version,
})); }));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -44,6 +50,7 @@ export class ExpenseCategoriesService {
}; };
}); });
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -59,14 +66,15 @@ export class ExpenseCategoriesService {
version: { increment: 1 }, version: { increment: 1 },
}, },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_CATEGORIES
) ) {
conflict(); conflict();
}
return { return {
id, id,
name: label, name: label,
isDefault: row.seedKey === 'other', isDefault: row.seedKey === 'other',
version: version + 1, version: version + VERSION_INCREMENT,
}; };
}); });
} }
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations'; import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { periodBounds } from './bank-card-input';
@Injectable() @Injectable()
export class GetBankSummaryUseCase { export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {} constructor(private readonly repository: BankCardsRepository) {}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; 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 { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service'; import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service'; import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsController } from './presentation/bank-cards.controller';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule],
controllers: [BankCardsController], controllers: [BankCardsController],
@@ -1,3 +1,10 @@
export const FIRST_PAGE = 1;
export const MIN_ENTITY_VERSION = 1;
export const MIN_CATEGORY_NAME_LENGTH = 1;
export const MIN_CARD_NAME_LENGTH = 1;
export const MAX_CARD_NAME_LENGTH = 100; export const MAX_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80; export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500; export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -1,4 +1,6 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money';
export function moscowToday(now = new Date()): string { export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', { return new Intl.DateTimeFormat('en-CA', {
@@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string {
} }
export function isCalendarDate(value: string): boolean { export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false; if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false; if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`); const date = new Date(`${value}T00:00:00.000Z`);
return ( return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value !Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
); );
} }
@@ -22,10 +24,10 @@ export function bankBalance(
cardId: string, cardId: string,
opening: bigint, opening: bigint,
operations: Array<{ operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint; amountKopecks: bigint;
cardId: string;
kind: string;
targetCardId: null | string;
}>, }>,
): bigint { ): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories. // ponytail: O(N) history fold; optimize only after measuring growing histories.
@@ -35,19 +37,21 @@ export function bankBalance(
if ( if (
operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer operation.kind === BankOperationKind.Transfer
) ) {
balance -= operation.amountKopecks; balance -= operation.amountKopecks;
else if ( } else if (
operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
if ( if (
operation.kind === BankOperationKind.Transfer && operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId operation.targetCardId === cardId
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
return balance; return balance;
} }
@@ -55,17 +59,17 @@ export function bankBalance(
export function bankSummary( export function bankSummary(
period: { from: string; to: string }, period: { from: string; to: string },
operations: Array<{ operations: Array<{
kind: string;
date: string;
amountKopecks: bigint; amountKopecks: bigint;
categoryId: string | null; categoryId: null | string;
date: string;
kind: string;
}>, }>,
) { ) {
let expenses = 0n; let expenses = ZERO_KOPECKS;
let refunds = 0n; let refunds = ZERO_KOPECKS;
const categories = new Map< const categories = new Map<
string, 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. // ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) { for (const operation of operations) {
@@ -73,24 +77,25 @@ export function bankSummary(
if ( if (
operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund operation.kind !== BankOperationKind.Refund
) ) {
continue; continue;
}
const expense = const expense =
operation.kind === BankOperationKind.Expense operation.kind === BankOperationKind.Expense
? operation.amountKopecks ? operation.amountKopecks
: 0n; : ZERO_KOPECKS;
const refund = const refund =
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund
? operation.amountKopecks ? operation.amountKopecks
: 0n; : ZERO_KOPECKS;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? { const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId, categoryId: operation.categoryId,
expenses: 0n, expenses: ZERO_KOPECKS,
refunds: 0n, refunds: ZERO_KOPECKS,
net: 0n, net: ZERO_KOPECKS,
}; };
category.expenses += expense; category.expenses += expense;
category.refunds += refund; category.refunds += refund;
@@ -39,8 +39,9 @@ describe('RUB money', () => {
}); });
it('requires positive operation amounts, including at the maximum', () => { 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 }, true)).toThrow();
}
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect( expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
@@ -1,6 +1,11 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
export const ZERO_KOPECKS = 0n;
const RUB_DECIMAL_PLACES = 2;
const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n; const MAX_KOPECKS = 9_223_372_036_854_775_807n;
@@ -9,22 +14,26 @@ export function parseRubMoney(money: Money, positive = false): bigint {
money?.currency !== 'RUB' || money?.currency !== 'RUB' ||
typeof money.value !== 'string' || typeof money.value !== 'string' ||
money.value !== money.value.trim() money.value !== money.value.trim()
) ) {
throw new TypeError('Expected RUB money with a decimal string'); throw new TypeError('Expected RUB money with a decimal string');
}
const amount = decimalFromString(money.value); const amount = decimalFromString(money.value);
if (amount.scale > 2) if (amount.scale > RUB_DECIMAL_PLACES) {
throw new RangeError('RUB money supports at most two decimal places'); 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) const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
throw new RangeError('RUB money is outside the signed 64-bit range'); throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n) }
if (positive && kopecks <= ZERO_KOPECKS) {
throw new RangeError('Operation amount must be positive'); throw new RangeError('Operation amount must be positive');
}
return kopecks; return kopecks;
} }
export function rubMoney(kopecks: bigint): Money { export function rubMoney(kopecks: bigint): Money {
return { return {
currency: 'RUB', currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }), value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
}; };
} }
@@ -1,14 +1,19 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000; const TRANSACTION_TIMEOUT_MS = 15_000;
@@ -28,7 +33,7 @@ const seeds = [
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
return await this.prisma.$transaction(run, { return await this.prisma.$transaction(run, {
maxWait: TRANSACTION_MAX_WAIT_MS, maxWait: TRANSACTION_MAX_WAIT_MS,
@@ -40,37 +45,41 @@ export class BankCardsRepository {
? error.code ? error.code
: ''; : '';
const busy = const busy =
code === 'P2034' || isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
code === 'P2028' || isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) {
continue; continue;
}
if (busy) conflict(BankErrorCode.WriteConflict); if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
throw error; throw error;
} }
} }
} }
async card(tx: Tx, userId: string, id: string, active = false) { async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } }); const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing(); if (!card) return missing();
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card; return card;
} }
async operation(tx: Tx, userId: string, id: string) { async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing(); if (!row) return missing();
return row; return row;
} }
async seed(tx: Tx, userId: string) { async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) { for (const [index, label] of seeds.entries()) {
const seedKey = index === seeds.length - 1 ? 'other' : String(index); const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
if ( if (
!(await tx.expenseCategory.findUnique({ !(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } }, where: { userId_seedKey: { userId, seedKey } },
})) }))
) ) {
await tx.expenseCategory.create({ await tx.expenseCategory.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -80,8 +89,10 @@ export class BankCardsRepository {
nameKey: label.toLocaleLowerCase('ru-RU'), nameKey: label.toLocaleLowerCase('ru-RU'),
}, },
}); });
}
} }
} }
summaryData( summaryData(
userId: string, userId: string,
period: { from: string; to: string }, period: { from: string; to: string },
@@ -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 { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
@@ -28,7 +22,20 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import {
FIRST_PAGE,
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
MIN_CARD_NAME_LENGTH,
MIN_CATEGORY_NAME_LENGTH,
MIN_ENTITY_VERSION,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
const MIN_PAGE_SIZE = 1;
export const DEFAULT_BANK_PAGE_SIZE = 50; export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100; const MAX_BANK_PAGE_SIZE = 100;
@@ -41,17 +48,19 @@ export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString() @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})?$/) @Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string; declare value: string;
} }
export class CreateBankCardDto { export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH }) @ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CARD_NAME_LENGTH)
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string; declare name: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,
nullable: true, nullable: true,
@@ -61,20 +70,24 @@ export class CreateBankCardDto {
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null; bankName?: null | string;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional() @IsOptional()
@Matches(/^[0-9]{4}$/) @Matches(/^\d{4}$/)
lastFour?: string | null; lastFour?: null | string;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional() @IsOptional()
@IsIn(bankPaymentSystems) @IsIn(bankPaymentSystems)
paymentSystem?: string | null; paymentSystem?: null | string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto; declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@@ -84,50 +97,56 @@ export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const), OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
@ApiPropertyOptional() @ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean() @IsBoolean()
archived?: boolean; archived?: boolean;
} }
export class CategoryNameDto { export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) @ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CATEGORY_NAME_LENGTH)
@MaxLength(MAX_CATEGORY_NAME_LENGTH) @MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string; declare name: string;
} }
export class UpdateExpenseCategoryDto extends CategoryNameDto { export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
} }
export class CreateBankOperationDto { export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds }) @ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds) @IsIn(bankOperationKinds)
declare kind: BankOperationKind; declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
targetCardId?: string; targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
originalExpenseId?: string; originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare amount: MoneyDto; declare amount: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string; declare date: string;
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsString() @IsString()
@@ -138,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
) { ) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number; @ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
} }
export class BankCardsQueryDto { export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] }) @ApiPropertyOptional({ enum: ['true', 'false'] })
@@ -147,21 +166,22 @@ export class BankCardsQueryDto {
includeArchived?: string; includeArchived?: string;
} }
export class BankPageQueryDto { export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 }) @ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(FIRST_PAGE)
page?: number; page?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
minimum: 1, minimum: MIN_PAGE_SIZE,
maximum: MAX_BANK_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE, default: DEFAULT_BANK_PAGE_SIZE,
}) })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_PAGE_SIZE)
@Max(MAX_BANK_PAGE_SIZE) @Max(MAX_BANK_PAGE_SIZE)
limit?: number; limit?: number;
} }
@@ -173,6 +193,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/) @Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string; month?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive start date; requires to.', description: 'Inclusive start date; requires to.',
@@ -180,6 +201,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string; from?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive end date; requires from.', description: 'Inclusive end date; requires from.',
@@ -199,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
cardId?: string; cardId?: string;
} }
export class BankVersionQueryDto { export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 }) @ApiProperty({ minimum: MIN_ENTITY_VERSION })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_ENTITY_VERSION)
declare version: number; declare version: number;
} }
export class ExpenseCategoryDto { export class ExpenseCategoryDto {
@@ -215,16 +237,20 @@ export class BankCardDto {
@ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName: @ApiProperty({ type: String, nullable: true }) declare bankName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare lastFour: @ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null; null | string;
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) @ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null; declare paymentSystem: null | string;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt: @ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
} }
export class BankOperationDto { export class BankOperationDto {
@@ -233,27 +259,35 @@ export class BankOperationDto {
@ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId: @ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare targetCardName: @ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryId: @ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryName: @ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId: @ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null; null | string;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null; declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description: @ApiProperty({ type: String, nullable: true }) declare description:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string; @ApiProperty({ type: String }) declare createdAt: string;
} }
export class BankOperationsPageDto { export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true }) @ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[]; declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean; @ApiProperty({ type: Boolean }) declare hasNext: boolean;
@@ -269,7 +303,7 @@ export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: 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 expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@@ -1,3 +1,5 @@
import type { Request } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -21,12 +23,16 @@ import {
ApiTags, ApiTags,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request } from 'express';
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator'; } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; 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 { import {
BankCardDto, BankCardDto,
BankCardsQueryDto, BankCardsQueryDto,
@@ -45,10 +51,6 @@ import {
UpdateBankOperationDto, UpdateBankOperationDto,
UpdateExpenseCategoryDto, UpdateExpenseCategoryDto,
} from './bank-card.dto'; } 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 }; type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
@@ -82,26 +84,31 @@ export class BankCardsController {
private readonly expenseCategories: ExpenseCategoriesService, private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase, private readonly bankSummary: GetBankSummaryUseCase,
) {} ) {}
@Get() @Get()
@ApiOkResponse({ type: BankCardDto, isArray: true }) @ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true'); return this.cards.list(req.user.sub, query.includeArchived === 'true');
} }
@Post() @Post()
@ApiCreatedResponse({ type: BankCardDto }) @ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body); return this.cards.createCard(req.user.sub, body);
} }
@Get('categories') @Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) { categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub); return this.expenseCategories.categories(req.user.sub);
} }
@Post('categories') @Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto }) @ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name); return this.expenseCategories.createCategory(req.user.sub, body.name);
} }
@Patch('categories/:id') @Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto }) @ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory( updateCategory(
@@ -116,11 +123,13 @@ export class BankCardsController {
body.version, body.version,
); );
} }
@Get('summary') @Get('summary')
@ApiOkResponse({ type: BankSummaryDto }) @ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query); return this.bankSummary.execute(req.user.sub, query);
} }
@Post('operations') @Post('operations')
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
createOperation( createOperation(
@@ -129,11 +138,13 @@ export class BankCardsController {
) { ) {
return this.operations.createOperation(req.user.sub, body); return this.operations.createOperation(req.user.sub, body);
} }
@Get('operations/:id') @Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) { operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id); return this.operations.getOperation(req.user.sub, id);
} }
@Patch('operations/:id') @Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
updateOperation( updateOperation(
@@ -143,6 +154,7 @@ export class BankCardsController {
) { ) {
return this.operations.updateOperation(req.user.sub, id, body); return this.operations.updateOperation(req.user.sub, id, body);
} }
@Delete('operations/:id') @Delete('operations/:id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
@@ -153,6 +165,7 @@ export class BankCardsController {
) { ) {
return this.operations.deleteOperation(req.user.sub, id, query.version); return this.operations.deleteOperation(req.user.sub, id, query.version);
} }
@Get(':id/operations') @Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
history( history(
@@ -162,6 +175,7 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query); return this.operations.history(req.user.sub, id, query);
} }
@Get(':id/expenses') @Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
expenses( expenses(
@@ -171,11 +185,13 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query, true); return this.operations.history(req.user.sub, id, query, true);
} }
@Get(':id') @Get(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) { get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id); return this.cards.get(req.user.sub, id);
} }
@Patch(':id') @Patch(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
update( update(
@@ -1,6 +1,7 @@
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -40,15 +41,18 @@ export class BrokerageAccountDto {
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' }) @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 }) @ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null; portfolioSummary?: null | PortfolioSummaryDto;
} }
export class BrokerageAccountsResourceDto { 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 }) @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
declare data: BrokerageAccountDto[] | null; declare data: BrokerageAccountDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -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 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 { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,7 +1,8 @@
import { PortfolioModule } from '../portfolio/portfolio.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,8 +1,8 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { BrokerageAccountsService } from './brokerage-accounts.service';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageAccountsService } from './brokerage-accounts.service';
jest.mock('../portfolio/application/portfolio.service', () => ({ jest.mock('../portfolio/application/portfolio.service', () => ({
PortfolioService: class PortfolioService {}, PortfolioService: class PortfolioService {},
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PortfolioService } from '../portfolio/portfolio.api';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
@Injectable() @Injectable()
@@ -1,14 +1,16 @@
import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { import {
IncomeAnalyticsResourceDto,
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto, IncomeSummaryResourceDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income-analytics.service';
@ApiTags('brokerage-accounts') @ApiTags('brokerage-accounts')
@@ -1,13 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; 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 { import {
InvestmentDependencyDto, InvestmentDependencyDto,
InvestmentReadQueryDto, InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../investment-resource.dto'; } from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
@@ -102,16 +102,16 @@ export class IncomeInstrumentDto {
declare key: string; declare key: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare figi: string | null; declare figi: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null; declare instrumentUid: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare ticker: string | null; declare ticker: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null; declare instrumentType: null | string;
@ApiProperty() @ApiProperty()
declare displayName: string; declare displayName: string;
@@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
declare yearComparison: IncomeComparisonDto | null; declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'week' | 'month'; declare bucketGranularity: 'day' | 'month' | 'week';
@ApiProperty({ type: IncomeBucketDto, isArray: true }) @ApiProperty({ type: IncomeBucketDto, isArray: true })
declare buckets: IncomeBucketDto[]; declare buckets: IncomeBucketDto[];
@@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
export class IncomeSummaryResourceDto { export class IncomeSummaryResourceDto {
@ApiProperty({ type: IncomeSummaryDto, nullable: true }) @ApiProperty({ type: IncomeSummaryDto, nullable: true })
declare data: IncomeSummaryDto | null; declare data: IncomeSummaryDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto {
} }
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto { export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
@ApiProperty({ @ApiProperty({
type: IncomeDependencyDto, type: IncomeDependencyDto,
isArray: true, isArray: true,
@@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
export class IncomeAnalyticsResourceDto { export class IncomeAnalyticsResourceDto {
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true }) @ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
declare data: IncomeAnalyticsDto | null; declare data: IncomeAnalyticsDto | null;
@ApiProperty({ type: IncomeAnalyticsMetaDto }) @ApiProperty({ type: IncomeAnalyticsMetaDto })
declare meta: IncomeAnalyticsMetaDto; declare meta: IncomeAnalyticsMetaDto;
} }
@@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
})); }));
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => { describe('IncomeAnalyticsService', () => {
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import assert from 'node:assert/strict';
import { import {
add, add,
compare, compare,
@@ -7,24 +8,22 @@ import {
formatDecimal, formatDecimal,
subtract, subtract,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import {
IncomeAnalyticsResourceDto,
IncomeDependencyDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
BrokerageOperationDto, BrokerageOperationDto,
BrokerageOperationsService, BrokerageOperationsService,
} from '../operations/operations.api'; } from '../operations/operations.api';
import { import {
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto,
IncomeComparisonDto, IncomeComparisonDto,
IncomeCurrencySummaryDto, IncomeCurrencySummaryDto,
IncomeDependencyDto,
IncomeInstrumentDto, IncomeInstrumentDto,
IncomeSummaryDto, IncomeSummaryDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { import {
IncomeBucketGranularity, IncomeBucketGranularity,
@@ -32,6 +31,8 @@ import {
rangeBuckets, rangeBuckets,
} from './income-period'; } from './income-period';
const STALE_FRESHNESS_MS = 0;
type Total = { type Total = {
coupon: ReturnType<typeof decimalFromString>; coupon: ReturnType<typeof decimalFromString>;
dividend: ReturnType<typeof decimalFromString>; dividend: ReturnType<typeof decimalFromString>;
@@ -122,7 +123,7 @@ export class IncomeAnalyticsService {
fetchedAt: current.meta.lastSuccessfulAt fetchedAt: current.meta.lastSuccessfulAt
? new Date(current.meta.lastSuccessfulAt) ? new Date(current.meta.lastSuccessfulAt)
: null, : null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage, coverage: current.meta.coverage,
dependencies, dependencies,
@@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter( return entries.filter(
(entry) => (entry) =>
Boolean(entry.payment) && Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
); );
} }
@@ -184,9 +185,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
if (!entry.payment) continue; if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal(); const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value); const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON') if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value); 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); values.set(entry.payment.currency, total);
} }
return [...values] return [...values]
@@ -207,6 +208,7 @@ function comparison(
); );
const currencies = [ const currencies = [
...new Set([...selected.keys(), ...compared.keys()]), ...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(); ].sort();
return { return {
period: comparedPeriod, period: comparedPeriod,
@@ -279,8 +281,9 @@ function instruments(
function instrumentKey(entry: BrokerageOperationDto): string { function instrumentKey(entry: BrokerageOperationDto): string {
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`; if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
if (entry.figi) return `figi:${entry.figi}`; if (entry.figi) return `figi:${entry.figi}`;
if (entry.ticker || entry.instrumentType) if (entry.ticker || entry.instrumentType) {
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`; return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
}
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
} }
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { dateOnlyBounds } from '../investment-calendar'; import { dateOnlyBounds } from '../investment-calendar';
import { planIncomePeriod, rangeBuckets } from './income-period'; import { planIncomePeriod, rangeBuckets } from './income-period';
@@ -17,8 +18,9 @@ describe('income period planning', () => {
['2026-03-02', '2026-03-01'], ['2026-03-02', '2026-03-01'],
['2026-01-01', '2027-01-02'], ['2026-01-01', '2027-01-02'],
['2026-01-01', '2026-04-01'], ['2026-01-01', '2026-04-01'],
]) ]) {
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException); expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
}
}); });
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => { it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
+33 -20
View File
@@ -1,5 +1,6 @@
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -7,23 +8,33 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0;
const PREVIOUS_DAY_OFFSET = -1;
const PREVIOUS_YEAR_OFFSET = 1;
const DATE_COMPONENT_DIGITS = 2;
const DAILY_BUCKET_MAX_DAYS = 45; const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180; const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7; const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'week' | 'month'; export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
openedDate?: Date, openedDate?: Date,
): { ): {
selected: DateOnlyRange;
previous: DateOnlyRange;
yearAgo: DateOnlyRange | null;
granularity: IncomeBucketGranularity; granularity: IncomeBucketGranularity;
previous: DateOnlyRange;
selected: DateOnlyRange;
yearAgo: DateOnlyRange | null;
} { } {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date()); const today = moscowDate(new Date());
@@ -55,29 +66,30 @@ export function rangeBuckets(
range: DateOnlyRange, range: DateOnlyRange,
granularity: IncomeBucketGranularity, granularity: IncomeBucketGranularity,
): DateOnlyRange[] { ): DateOnlyRange[] {
if (granularity === 'day') if (granularity === 'day') {
return dates(range).map((date) => ({ from: date, to: date })); return dates(range).map((date) => ({ from: date, to: date }));
}
const buckets: DateOnlyRange[] = []; const buckets: DateOnlyRange[] = [];
let cursor = let cursor =
granularity === 'week' granularity === 'week'
? addDays( ? addDays(
range.from, range.from,
-( -(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
DAYS_PER_WEEK DAYS_PER_WEEK
), ),
) )
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; : `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' granularity === 'week'
? addDays(cursor, DAYS_PER_WEEK - 1) ? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
: endOfMonth(cursor); : endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
}); });
cursor = addDays(end, 1); cursor = addDays(end, NEXT_DAY_OFFSET);
} }
return buckets; return buckets;
} }
@@ -86,8 +98,8 @@ export function rangeDays(range: DateOnlyRange): number {
return ( return (
Math.round( Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
) + 1 ) + INCLUSIVE_DAY_OFFSET
); );
} }
@@ -104,23 +116,24 @@ function assertRange(range: DateOnlyRange, today: string): void {
function addDays(value: string, days: number): string { function addDays(value: string, days: number): string {
const date = utcDate(value); const date = utcDate(value);
date.setUTCDate(date.getUTCDate() + days); date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10); return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function dates(range: DateOnlyRange): string[] { function dates(range: DateOnlyRange): string[] {
const result: string[] = []; const result: string[] = [];
for (let date = range.from; date <= range.to; date = addDays(date, 1)) for (let date = range.from; date <= range.to; date = addDays(date, NEXT_DAY_OFFSET)) {
result.push(date); result.push(date);
}
return result; return result;
} }
function firstOfMonth(value: string): string { function firstOfMonth(value: string): string {
return `${value.slice(0, ISO_MONTH_LENGTH)}-01`; return `${value.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
} }
function endOfMonth(value: string): string { function endOfMonth(value: string): string {
const [year, month] = dateParts(firstOfMonth(value)); const [year, month] = dateParts(firstOfMonth(value));
return new Date(Date.UTC(year, month, 0)).toISOString().slice(0, 10); return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function isFullMonth(range: DateOnlyRange): boolean { function isFullMonth(range: DateOnlyRange): boolean {
@@ -130,7 +143,7 @@ function isFullMonth(range: DateOnlyRange): boolean {
} }
function previousMonth(from: string): DateOnlyRange { function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), -1); const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
return { from: firstOfMonth(last), to: last }; return { from: firstOfMonth(last), to: last };
} }
@@ -141,8 +154,8 @@ function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
function yearAgo(range: DateOnlyRange): DateOnlyRange | null { function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); 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')}`; const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
? result ? result
: null; : null;
}; };
@@ -152,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
} }
function invalidPeriod(): BadRequestException { function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' }); return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
} }
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { OperationsModule } from '../operations/operations.module'; import { OperationsModule } from '../operations/operations.module';
import { IncomeAnalyticsController } from './income-analytics.controller'; import { IncomeAnalyticsController } from './income-analytics.controller';
@@ -0,0 +1,2 @@
export const MIN_INSTRUMENT_QUERY_LENGTH = 2;
export const MAX_INSTRUMENT_QUERY_LENGTH = 80;
@@ -1,14 +1,15 @@
import { planInvestmentScreen } from '../../investment-sync-plan';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal'; 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 { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
@@ -16,35 +17,42 @@ import {
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { planInvestmentScreen } from '../../investment-sync-plan';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
candleGaps, candleGaps,
type CandleRange,
candleWindows, candleWindows,
utcDay, utcDay,
type CandleRange,
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { CandleRepository } from '../infrastructure/candle.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market'; import { timestamp } from './instrument-market';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1;
type CandleInput = { type CandleInput = {
uid: string; instrumentType: 'bond' | 'share';
instrumentType: 'share' | 'bond'; period: 'all' | 'halfYear' | 'month' | 'year';
period: 'month' | 'halfYear' | 'year' | 'all';
range?: CandleRange; range?: CandleRange;
uid: string;
}; };
type Producer = { type Producer = {
input: CandleInput;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
now: Date;
force: boolean; force: boolean;
priority: number; input: CandleInput;
now: Date;
observers: TbankSyncAttempt[]; observers: TbankSyncAttempt[];
pass?: { pass?: {
now: Date;
force: boolean; force: boolean;
ranges: CandleRange[];
nextWindow: number; nextWindow: number;
now: Date;
ranges: CandleRange[];
}; };
priority: number;
}; };
const months = { month: 1, halfYear: 6, year: 12 }; const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000; const tailFreshForMs = 60_000;
@@ -61,18 +69,20 @@ function rangeFor(
const from = utcDay(now); const from = utcDay(now);
const history = timestamp(instrument.first_1dayCandleDate); const history = timestamp(instrument.first_1dayCandleDate);
if (input.period === 'all') { if (input.period === 'all') {
if (!history) if (!history) {
throw new ServiceUnavailableException({ throw new ServiceUnavailableException({
code: code:
input.instrumentType === 'share' input.instrumentType === 'share'
? 'STOCK_HISTORY_START_UNAVAILABLE' ? InstrumentErrorCode.StockHistoryStartUnavailable
: 'BOND_HISTORY_START_UNAVAILABLE', : InstrumentErrorCode.BondHistoryStartUnavailable,
}); });
}
from.setTime(utcDay(new Date(history)).valueOf()); from.setTime(utcDay(new Date(history)).valueOf());
} else { } else {
from.setUTCMonth(from.getUTCMonth() - months[input.period]); 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()); from.setTime(utcDay(new Date(history)).valueOf());
}
} }
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
} }
@@ -102,16 +112,18 @@ export class CandleService {
true, true,
['detail'], ['detail'],
); );
if (detail.meta.availability === 'complete' && !detail.instrument) if (detail.meta.availability === 'complete' && !detail.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const now = new Date(); const now = new Date();
const range = detail.instrument const range = detail.instrument
? rangeFor(input, detail.instrument, now) ? rangeFor(input, detail.instrument, now)
: null; : null;
const saved = range ? await this.repository.read(input.uid, range) : null; const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; 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); await this.request(userId, revision, input, now);
}
const work = await this.repository.work(userId, revision, scope(input)); const work = await this.repository.work(userId, revision, scope(input));
const fetchedAt = const fetchedAt =
saved?.coverage saved?.coverage
@@ -197,13 +209,14 @@ export class CandleService {
prefetch( prefetch(
userId: string, userId: string,
revision: string, revision: string,
positions: { instrumentUid?: string | null; type: string }[], positions: { instrumentUid?: null | string; type: string }[],
) { ) {
const key = JSON.stringify([ const key = JSON.stringify([
userId, userId,
revision, revision,
positions positions
.map((position) => [position.instrumentUid, position.type]) .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(), .sort(),
]); ]);
const existing = this.prefetches.get(key); const existing = this.prefetches.get(key);
@@ -218,14 +231,14 @@ export class CandleService {
private async prefetchPositions( private async prefetchPositions(
userId: string, userId: string,
revision: string, revision: string,
positions: { instrumentUid?: string | null; type: string }[], positions: { instrumentUid?: null | string; type: string }[],
) { ) {
const now = new Date(); const now = new Date();
const unique = new Map( const unique = new Map(
positions positions
.filter( .filter(
(position) => (position) =>
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
) )
.map((position) => [position.instrumentUid ?? '', position]), .map((position) => [position.instrumentUid ?? '', position]),
); );
@@ -240,7 +253,7 @@ export class CandleService {
) )
.map(([uid]) => uid); .map(([uid]) => uid);
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += prefetchReadBatchSize offset += prefetchReadBatchSize
) { ) {
@@ -267,7 +280,7 @@ export class CandleService {
instruments: [ instruments: [
{ {
uid, uid,
type: position.type as 'STOCK' | 'BOND', type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null, historyFrom: history ? new Date(history) : null,
}, },
], ],
@@ -290,8 +303,9 @@ export class CandleService {
coverage.filter((row) => row.instrumentUid === uid), coverage.filter((row) => row.instrumentUid === uid),
now, now,
).length ).length
) ) {
await this.request(userId, revision, input, now, false, 2); await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
}
} }
} }
} }
@@ -379,13 +393,15 @@ export class CandleService {
} }
const { ranges, now, force } = pass; const { ranges, now, force } = pass;
const index = pass.nextWindow; const index = pass.nextWindow;
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
let window = ranges[index]; let window = ranges[index];
if (window && !force) { if (window && !force) {
const latest = await this.repository.read(input.uid, window); const latest = await this.repository.read(input.uid, window);
const gaps = candleGaps(window, latest.coverage, now) const gaps = candleGaps(window, latest.coverage, now)
.flatMap(candleWindows) .flatMap(candleWindows)
.reverse(); .reverse();
ranges.splice(index, 1, ...gaps); ranges.splice(index, REPLACED_WINDOW_COUNT, ...gaps);
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
window = ranges[index]; window = ranges[index];
} }
if (window) { if (window) {
@@ -408,8 +424,9 @@ export class CandleService {
}, },
]; ];
}); });
if (!(await this.repository.stage(attempt, window, rows, now))) if (!(await this.repository.stage(attempt, window, rows, now))) {
return false; return false;
}
if ( if (
!force && !force &&
!(await this.repository.publish( !(await this.repository.publish(
@@ -417,9 +434,10 @@ export class CandleService {
{ uid: input.uid, ranges: [window] }, { uid: input.uid, ranges: [window] },
false, false,
)) ))
) ) {
return false; return false;
pass.nextWindow = index + 1; }
pass.nextWindow = index + NEXT_WINDOW_OFFSET;
if (pass.nextWindow < ranges.length) return null; if (pass.nextWindow < ranges.length) return null;
} }
return true; return true;
@@ -432,7 +450,7 @@ export class CandleService {
const producer = this.queue.shift(); const producer = this.queue.shift();
if (!producer) break; if (!producer) break;
let success = false; let success = false;
let code = 'TBANK_UNAVAILABLE'; let code: string = TbankErrorCode.Unavailable;
try { try {
const result = await this.load(producer); const result = await this.load(producer);
if (result === null) { if (result === null) {
@@ -441,16 +459,18 @@ export class CandleService {
} }
success = result; success = result;
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
}
if (error instanceof ServiceUnavailableException) { if (error instanceof ServiceUnavailableException) {
const response = error.getResponse(); const response = error.getResponse();
if ( if (
typeof response === 'object' && typeof response === 'object' &&
'code' in response && 'code' in response &&
typeof response.code === 'string' typeof response.code === 'string'
) ) {
code = response.code; code = response.code;
}
} }
} }
// Seal publication with reservations so a late forced join cannot be // Seal publication with reservations so a late forced join cannot be
@@ -458,8 +478,9 @@ export class CandleService {
const finished = this.reservations.then(async () => { const finished = this.reservations.then(async () => {
const pass = producer.pass; const pass = producer.pass;
if (success && pass) { if (success && pass) {
if (producer.force && (!pass.force || producer.now > pass.now)) if (producer.force && (!pass.force || producer.now > pass.now)) {
return false; return false;
}
try { try {
success = await this.repository.publish(producer.attempt, { success = await this.repository.publish(producer.attempt, {
uid: producer.input.uid, uid: producer.input.uid,
@@ -478,8 +499,9 @@ export class CandleService {
observer, observer,
producer.attempt, producer.attempt,
)) ))
) ) {
await this.repository.fail(observer, code); await this.repository.fail(observer, code);
}
this.active.delete(observer.attemptId); this.active.delete(observer.attemptId);
} }
this.producers.delete(scope(producer.input)); this.producers.delete(scope(producer.input));
@@ -490,19 +512,20 @@ export class CandleService {
this.queue.push(producer); this.queue.push(producer);
continue; continue;
} }
if (success && !producer.force && producer.input.period !== 'all') if (success && !producer.force && producer.input.period !== 'all') {
await this.request( await this.request(
producer.attempt.userId, producer.attempt.userId,
producer.attempt.revision, producer.attempt.revision,
{ ...producer.input, period: 'all' }, { ...producer.input, period: 'all' },
producer.now, producer.now,
false, false,
2, BACKGROUND_SYNC_PRIORITY,
); );
}
} }
} catch { } catch {
this.logger.event('error', 'candles.persistence-failed', { this.logger.event('error', 'candles.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.active.clear(); this.active.clear();
@@ -1,11 +1,12 @@
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
@@ -14,24 +15,30 @@ import {
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
catalogScope,
type CatalogQuery, type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
catalogSearchLimit, catalogSearchLimit,
selectCatalogInstruments, selectCatalogInstruments,
} from './catalog-selection'; } from './catalog-selection';
const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
type Producer = { type Producer = {
input: CatalogQuery;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
observers: TbankSyncAttempt[];
force: boolean; force: boolean;
input: CatalogQuery;
observers: TbankSyncAttempt[];
}; };
@Injectable() @Injectable()
export class CatalogDiscoveryService { export class CatalogDiscoveryService {
@@ -43,8 +50,9 @@ export class CatalogDiscoveryService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ workId: string; done: Promise<void>; resolve: () => void } { done: Promise<void>; resolve: () => void; workId: string }
>(); >();
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository, private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
@@ -131,7 +139,7 @@ export class CatalogDiscoveryService {
async detail( async detail(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
observe = false, observe = false,
) { ) {
@@ -143,22 +151,24 @@ export class CatalogDiscoveryService {
['detail'], ['detail'],
); );
const source = sources[0]; const source = sources[0];
if (source.meta.availability === 'complete' && !source.instrument) if (source.meta.availability === 'complete' && !source.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const instrument = source.instrument; const instrument = source.instrument;
let slug = uid; let slug = uid;
const dependencies = [source.meta]; const dependencies = [source.meta];
if ( if (
instrument?.ticker && 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( const alias = await this.read(
userId, userId,
{ instrumentType, query: instrument.ticker, exactTicker: true }, { instrumentType, query: instrument.ticker, exactTicker: true },
observe, observe,
); );
if (alias.data?.length === 1 && alias.data[0].uid === uid) if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase(); slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta); dependencies.push(alias.meta);
} }
return { return {
@@ -187,7 +197,7 @@ export class CatalogDiscoveryService {
async byTicker( async byTicker(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false, observe = false,
) { ) {
@@ -196,14 +206,15 @@ export class CatalogDiscoveryService {
{ instrumentType, query: ticker, exactTicker: true }, { instrumentType, query: ticker, exactTicker: true },
observe, observe,
); );
if (lookup.data?.length === 0) throw new NotFoundException(); if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > 1) if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' instrumentType === 'share'
? 'STOCK_TICKER_AMBIGUOUS' ? InstrumentErrorCode.StockTickerAmbiguous
: 'BOND_TICKER_AMBIGUOUS', : InstrumentErrorCode.BondTickerAmbiguous,
}); });
}
const result = lookup.data const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null; : null;
@@ -245,8 +256,9 @@ export class CatalogDiscoveryService {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
if ( if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
) ) {
return null; return null;
}
const reservation = await this.repository.reserve( const reservation = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -291,12 +303,13 @@ export class CatalogDiscoveryService {
async refresh(userId: string, revision: string, query: CatalogQuery) { async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true); const id = await this.request(userId, revision, query, true);
if (id) if (id) {
await Promise.all( await Promise.all(
[...this.completions.values()] [...this.completions.values()]
.filter((entry) => entry.workId === id) .filter((entry) => entry.workId === id)
.map((entry) => entry.done), .map((entry) => entry.done),
); );
}
const saved = await this.repository.read(catalogQuery(query)); const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany( const sources = await this.enrichment.readMany(
userId, userId,
@@ -339,24 +352,25 @@ export class CatalogDiscoveryService {
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice( ).slice(
0, FIRST_RESULT_OFFSET,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
), ),
new Date(), new Date(),
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
else } else {
this.logger.event('warn', 'catalog-discovery.failed', { this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
}
} }
if (success) { if (success) {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === 1) if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
await this.enrichment.requestMany( await this.enrichment.requestMany(
observer.userId, observer.userId,
observer.revision, observer.revision,
@@ -365,6 +379,8 @@ export class CatalogDiscoveryService {
producer.force, producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'], input.exactTicker ? ['detail'] : ['last-price', 'detail'],
); );
}
}
} }
this.producers.delete(catalogScope(input)); this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) { for (const observer of [attempt, ...producer.observers]) {
@@ -381,7 +397,7 @@ export class CatalogDiscoveryService {
this.producers.clear(); this.producers.clear();
this.queue.length = 0; this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', { this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.running = false; this.running = false;
@@ -1,47 +1,52 @@
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CandleRepository } from '../infrastructure/candle.repository'; import { randomUUID } from 'node:crypto';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { import {
decimalFromString, decimalFromString,
divide, divide,
formatDecimal, formatDecimal,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND, MILLISECONDS_PER_SECOND,
} from '../../../../common/time'; } 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 { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { previousClose, timestamp } from './instrument-market'; import { decimal } from '../../../../integrations/tbank/decimal';
import { Injectable, NotFoundException } from '@nestjs/common'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
TbankClient,
type TbankCoupon, type TbankCoupon,
type TbankDividend, type TbankDividend,
type TbankFundamentals, type TbankFundamentals,
type TbankLastPrice, type TbankLastPrice,
type TbankShare, type TbankShare,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogScope, catalogScope,
type CatalogSource, type CatalogSource,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market';
const MISSING_DATA_REVISION = 0;
const FIRST_QUEUE_INDEX = 0;
const DEQUEUED_ITEM_COUNT = 1;
type Producer = { type Producer = {
input: CatalogSource;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
input: CatalogSource;
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
}; };
@Injectable() @Injectable()
@@ -54,13 +59,13 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ workId: string; done: Promise<void>; resolve: () => void } { done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
userId: string, userId: string,
revision: string, revision: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
) { ) {
const ids = await this.requestMany( const ids = await this.requestMany(
@@ -82,6 +87,7 @@ export class CatalogEnrichmentService {
if (!rows.length) throw new Error('Instrument dependency missing'); if (!rows.length) throw new Error('Instrument dependency missing');
return (rows[0].payload as CatalogPayload).instrument ?? null; return (rows[0].payload as CatalogPayload).instrument ?? null;
} }
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository, private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
@@ -94,21 +100,21 @@ export class CatalogEnrichmentService {
async readMany( async readMany(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
observe: boolean, observe: boolean,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'], kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
) { ) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const rows = inputs.length ? await this.repository.readSources(inputs) : []; const rows = inputs.length ? await this.repository.readSources(inputs) : [];
if (!observe) if (!observe) {
await this.requestMany( await this.requestMany(
userId, userId,
revision, revision,
@@ -118,6 +124,7 @@ export class CatalogEnrichmentService {
kinds, kinds,
range, range,
); );
}
const work = inputs.length const work = inputs.length
? await this.repository.workMany(userId, revision, inputs) ? await this.repository.workMany(userId, revision, inputs)
: []; : [];
@@ -145,7 +152,7 @@ export class CatalogEnrichmentService {
price: payload?.price ?? null, price: payload?.price ?? null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: catalogScope(input), resourceKey: catalogScope(input),
dataRevision: String(row?.dataRevision ?? 0), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
@@ -158,15 +165,15 @@ export class CatalogEnrichmentService {
requestMany( requestMany(
userId: string, userId: string,
revision: string, revision: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
force = false, force = false,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'], kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
@@ -187,8 +194,9 @@ export class CatalogEnrichmentService {
new Date(), new Date(),
force, force,
) )
) ) {
continue; continue;
}
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -229,9 +237,9 @@ export class CatalogEnrichmentService {
async section( async section(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
section: 'quote' | 'company' | 'metrics', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
@@ -256,8 +264,9 @@ export class CatalogEnrichmentService {
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument) if (first.meta.availability === 'complete' && !first.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const values = sources.reduce<CatalogPayload>( const values = sources.reduce<CatalogPayload>(
(result, source) => ({ ...result, ...source.payload }), (result, source) => ({ ...result, ...source.payload }),
{}, {},
@@ -310,15 +319,18 @@ export class CatalogEnrichmentService {
input.instrumentType === 'share' input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid) ? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid); : await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch'); 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'); if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null }; return { instrument: instrument ? publicInstrument(instrument) : null };
} }
if (input.kind === 'trading-status') if (input.kind === 'trading-status') {
return { return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid), tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
}; };
}
if (input.kind === 'comparison') { if (input.kind === 'comparison') {
const prices = await this.repository.readSources([ const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined }, { ...input, kind: 'last-price', rangeKey: undefined },
@@ -343,16 +355,18 @@ export class CatalogEnrichmentService {
); );
const rows = response.map((candle) => { const rows = response.map((candle) => {
const time = timestamp(candle.time); const time = timestamp(candle.time);
if (!time || !candle.close) if (!time || !candle.close) {
throw new Error('Invalid candle response'); throw new Error('Invalid candle response');
}
return { return {
time, time,
close: formatDecimal(decimal(candle.close)), close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true, 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'); throw new Error('Cancelled candle comparison');
}
} }
if ( if (
gaps.length && gaps.length &&
@@ -361,8 +375,9 @@ export class CatalogEnrichmentService {
{ uid: input.uid, ranges: gaps }, { uid: input.uid, ranges: gaps },
false, false,
)) ))
) ) {
throw new Error('Cancelled candle comparison'); throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map( const values = (await this.candles.read(input.uid, range)).data.map(
(row) => { (row) => {
const value = divide( const value = divide(
@@ -395,21 +410,24 @@ export class CatalogEnrichmentService {
.find(Boolean); .find(Boolean);
if ( if (
!instrument && !instrument &&
!['dividends', 'coupons', 'bond-events'].includes(input.kind) !['bond-events', 'coupons', 'dividends'].includes(input.kind)
) ) {
throw new Error('Instrument dependency missing'); throw new Error('Instrument dependency missing');
if (input.kind === 'description') }
if (input.kind === 'description') {
return { return {
description: instrument?.assetUid description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid) ? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null, : null,
}; };
if (input.kind === 'fundamentals') }
if (input.kind === 'fundamentals') {
return { return {
fundamentals: instrument?.assetUid fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid) ? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null, : null,
}; };
}
if (input.kind === 'coupons' || input.kind === 'dividends') { if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey); const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[') const [from, to] = input.rangeKey?.startsWith('[')
@@ -441,7 +459,7 @@ export class CatalogEnrichmentService {
if (!ticker) throw new Error('Bond ticker missing'); if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) }; return { bondEvents: await this.moex.bondEvents(ticker) };
} }
if (input.kind === 'yield') if (input.kind === 'yield') {
return { return {
effectiveYield: effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode instrument?.ticker && instrument.isin && instrument.classCode
@@ -452,12 +470,14 @@ export class CatalogEnrichmentService {
) )
: null, : null,
}; };
if (input.kind === 'rating') }
if (input.kind === 'rating') {
return { return {
creditRating: instrument?.isin creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin) ? await this.ratings.rating(instrument.isin)
: null, : null,
}; };
}
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
} }
@@ -470,9 +490,10 @@ export class CatalogEnrichmentService {
// One batch RPC for queued prices sharing the same authorized producer. // One batch RPC for queued prices sharing the same authorized producer.
if (producer.input.kind === 'last-price') { if (producer.input.kind === 'last-price') {
for ( for (
let i = 0; let i = FIRST_QUEUE_INDEX;
i < this.queue.length && batch.length < catalogSearchLimit; 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]; const next = this.queue[i];
if ( if (
next.input.kind === 'last-price' && next.input.kind === 'last-price' &&
@@ -480,7 +501,7 @@ export class CatalogEnrichmentService {
next.attempt.revision === producer.attempt.revision next.attempt.revision === producer.attempt.revision
) { ) {
batch.push(next); batch.push(next);
this.queue.splice(i, 1); this.queue.splice(i, DEQUEUED_ITEM_COUNT);
} else i++; } else i++;
} }
} }
@@ -490,21 +511,23 @@ export class CatalogEnrichmentService {
try { try {
const token = await this.access.tokenForAttempt(producer.attempt); const token = await this.access.tokenForAttempt(producer.attempt);
if (!token) failed = true; if (!token) failed = true;
else if (producer.input.kind === 'last-price') else if (producer.input.kind === 'last-price') {
prices = await this.tbank.getLastPrices( prices = await this.tbank.getLastPrices(
token, token,
batch.map((item) => item.input.uid), batch.map((item) => item.input.uid),
); );
else } else {
payload = await this.load(producer.input, token, producer.attempt); payload = await this.load(producer.input, token, producer.attempt);
}
} catch (error) { } catch (error) {
failed = true; failed = true;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
else } else {
this.logger.event('warn', 'catalog-enrichment.failed', { this.logger.event('warn', 'catalog-enrichment.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
}
} }
for (const item of batch) { for (const item of batch) {
const price = const price =
@@ -550,7 +573,7 @@ export class CatalogEnrichmentService {
this.producers.clear(); this.producers.clear();
this.queue.length = 0; this.queue.length = 0;
this.logger.event('error', 'catalog-enrichment.persistence-failed', { this.logger.event('error', 'catalog-enrichment.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.running = false; this.running = false;
@@ -558,14 +581,14 @@ export class CatalogEnrichmentService {
} }
} }
function sourceInputs( function sourceInputs(
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
kinds: CatalogSource['kind'][], kinds: CatalogSource['kind'][],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
): CatalogSource[] { ): CatalogSource[] {
return kinds.flatMap((kind) => return kinds.flatMap((kind) =>
@@ -573,7 +596,9 @@ function sourceInputs(
instrumentType, instrumentType,
uid, uid,
kind, kind,
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
? { ticker: range?.tickers?.[uid] || range?.ticker } ? { ticker: range?.tickers?.[uid] || range?.ticker }
: {}), : {}),
...(['coupons', 'dividends'].includes(kind) ...(['coupons', 'dividends'].includes(kind)
@@ -586,7 +611,7 @@ function sourceInputs(
})), })),
); );
} }
function publicInstrument(value: TbankShare | TbankBond) { function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond; const bond = value as TbankBond;
return { return {
uid: value.uid, uid: value.uid,
@@ -625,31 +650,32 @@ const previousCloseLookbackMs =
const payoutHistoryYears = 5; const payoutHistoryYears = 5;
const payoutFutureYears = 2; const payoutFutureYears = 2;
export type CatalogPayload = { 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[]; bondEvents?: MoexBondEvent[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>; candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>; creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
}; };
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
'last-price',
'bond-current', 'bond-current',
'trading-status',
'comparison', 'comparison',
'last-price',
'trading-status',
'yield', 'yield',
].includes(kind) ].includes(kind)
) ) {
return 'quote'; return 'quote';
}
if (kind === 'description' || kind === 'fundamentals') return 'company'; 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'; return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
@@ -1,17 +1,19 @@
import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30; export const catalogSearchLimit = 30;
export function selectCatalogInstruments<T extends TbankShare>( export function selectCatalogInstruments<T extends TbankShare>(
found: T[], found: T[],
query: string, query: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
): (T & { uid: string })[] { ): (T & { uid: string })[] {
const seen = new Set<string>(); const seen = new Set<string>();
const kind = const kind =
instrumentType === 'share' instrumentType === 'share'
? 'INSTRUMENT_TYPE_SHARE' ? TbankInstrumentType.Share
: 'INSTRUMENT_TYPE_BOND'; : TbankInstrumentType.Bond;
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found return found
.filter( .filter(
@@ -21,11 +23,11 @@ export function selectCatalogInstruments<T extends TbankShare>(
.sort( .sort(
(a, b) => (a, b) =>
Number(b.apiTradeAvailableFlag === true) - Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) || Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === preferredClass) - Number(b.classCode === preferredClass) -
Number(a.classCode === preferredClass) || Number(a.classCode === preferredClass) ||
a.uid.localeCompare(b.uid), a.uid.localeCompare(b.uid),
) )
.filter((instrument) => { .filter((instrument) => {
@@ -1,25 +1,28 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
const MOSCOW_OFFSET_MS = 10_800_000; 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); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0 return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null; : null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
.toISOString() .toISOString()
.slice(0, 10); .slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
export function previousClose( export function previousClose(
candles: TbankCandle[], candles: TbankCandle[],
priceAt: string, priceAt: string,
): TbankCandle | null { ): null | TbankCandle {
return ( return (
candles candles
.filter((candle) => { .filter((candle) => {
@@ -28,10 +31,10 @@ export function previousClose(
candle.isComplete && candle.isComplete &&
candle.close && candle.close &&
time && time &&
time.slice(0, 10) < moscowDay(priceAt) time.slice(ISO_DATE_START, ISO_DATE_LENGTH) < moscowDay(priceAt)
); );
}) })
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null null
); );
} }
@@ -5,7 +5,6 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
PortfolioDto, PortfolioDto,
@@ -13,13 +12,17 @@ import {
} from '../../portfolio/portfolio.api'; } from '../../portfolio/portfolio.api';
import { StockPositionDto } from '../stocks/presentation/stock.dto'; import { StockPositionDto } from '../stocks/presentation/stock.dto';
const EMPTY_POSITIONS_COUNT = 0;
const ZERO_QUANTITY = 0n;
const ZERO_AMOUNT = 0n;
const PRICE_DECIMAL_PLACES = 9; const PRICE_DECIMAL_PLACES = 9;
const RETURN_PERCENT_DECIMAL_PLACES = 6; const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition( export function instrumentPosition(
portfolio: PortfolioDto, portfolio: PortfolioDto,
uid: string, uid: string,
type: 'STOCK' | 'BOND', type: 'BOND' | 'STOCK',
): StockPositionDto { ): StockPositionDto {
const positions = portfolio.positions.filter( const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid, (p) => p.type === type && p.instrumentUid === uid,
@@ -33,8 +36,8 @@ export function instrumentPosition(
const currentValue = sumComplete(positions.map((p) => p.currentValue)); const currentValue = sumComplete(positions.map((p) => p.currentValue));
const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn)); const absoluteReturn = sumComplete(positions.map((p) => p.absoluteReturn));
const positive = const positive =
positions.length > 0 && positions.length > EMPTY_POSITIONS_COUNT &&
positions.every((p) => decimalFromString(p.quantity).value > 0n); positions.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY);
return { return {
quantity: formatDecimal(quantity), quantity: formatDecimal(quantity),
currentValue, currentValue,
@@ -56,7 +59,7 @@ export function instrumentPosition(
accounts: positions.map((p) => ({ accounts: positions.map((p) => ({
...p, ...p,
returnPercent: returnPercent:
decimalFromString(p.quantity).value > 0n decimalFromString(p.quantity).value > ZERO_QUANTITY
? percent(p.absoluteReturn, cost(p)) ? percent(p.absoluteReturn, cost(p))
: null, : null,
})), })),
@@ -83,8 +86,9 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
if ( if (
!currency || !currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency) !values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) ) {
return null; return null;
}
return { return {
currency, currency,
value: formatDecimal( value: formatDecimal(
@@ -98,14 +102,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
function percent( function percent(
result: MoneyDto | null, result: MoneyDto | null,
base: MoneyDto | null, base: MoneyDto | null,
): string | null { ): null | string {
if ( if (
!base || !base ||
!result || !result ||
base.currency !== result.currency || base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n decimalFromString(base.value).value <= ZERO_AMOUNT
) ) {
return null; return null;
}
return formatDecimal( return formatDecimal(
divide( divide(
multiply(decimalFromString(result.value), decimalFromString('100')), multiply(decimalFromString(result.value), decimalFromString('100')),
@@ -1,8 +1,10 @@
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
BondDto, BondDto,
@@ -12,8 +14,11 @@ import {
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0;
@Injectable() @Injectable()
export class BondCatalogService { export class BondCatalogService {
constructor(private readonly discovery: CatalogDiscoveryService) {} constructor(private readonly discovery: CatalogDiscoveryService) {}
@@ -49,6 +54,7 @@ export class BondCatalogService {
}) ?? null, }) ?? null,
}; };
} }
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
@@ -62,6 +68,7 @@ export class BondCatalogService {
: null, : null,
}; };
} }
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
@@ -96,13 +103,13 @@ function toBond(bond: IdentifiedBond): BondDto {
currency: bond.currency?.toUpperCase() || null, currency: bond.currency?.toUpperCase() || null,
bondType: bond.bondType || null, bondType: bond.bondType || null,
sector: bond.sector || null, sector: bond.sector || null,
lot: bond.lot && bond.lot > 0 ? bond.lot : null, lot: bond.lot && bond.lot > MIN_LOT_EXCLUSIVE ? bond.lot : null,
historyFrom: timestamp(bond.first_1dayCandleDate), historyFrom: timestamp(bond.first_1dayCandleDate),
nominal: money(bond.nominal), nominal: money(bond.nominal),
initialNominal: money(bond.initialNominal), initialNominal: money(bond.initialNominal),
aci: money(bond.aciValue), aci: money(bond.aciValue),
maturityDate: timestamp(bond.maturityDate)?.slice(0, 10) ?? null, maturityDate: timestamp(bond.maturityDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
callDate: timestamp(bond.callDate)?.slice(0, 10) ?? null, callDate: timestamp(bond.callDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
couponQuantityPerYear: bond.couponQuantityPerYear ?? null, couponQuantityPerYear: bond.couponQuantityPerYear ?? null,
floatingCoupon: bond.floatingCouponFlag ?? null, floatingCoupon: bond.floatingCouponFlag ?? null,
perpetual: bond.perpetualFlag ?? null, perpetual: bond.perpetualFlag ?? null,
@@ -1,18 +1,21 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { import {
decimalFromString, decimalFromString,
divide,
formatDecimal, formatDecimal,
multiply, multiply,
divide,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
import { import {
TbankAccessService, TbankAccessService,
type TbankCoupon,
type TbankBond, type TbankBond,
type TbankCoupon,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
PortfolioService,
ForecastService, ForecastService,
PortfolioService,
} from '../../../portfolio/portfolio.api'; } from '../../../portfolio/portfolio.api';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -20,15 +23,21 @@ import {
} from '../../application/catalog-enrichment.service'; } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market'; import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
import { money } from '../infrastructure/bond-money.mapper';
import { import {
BondCouponsDto, BondCouponsDto,
BondCouponsResourceDto, BondCouponsResourceDto,
BondPositionDto,
BondMetricsDto, BondMetricsDto,
BondMetricsResourceDto, BondMetricsResourceDto,
BondPositionDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
const INCOME_FORECAST_YEARS = 1;
const ZERO_QUANTITY = 0n;
const MIN_COUPON_PERIOD_EXCLUSIVE = 0;
const ZERO_AMOUNT = 0n;
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5; const INCOME_HISTORY_YEARS = 5;
const COUPON_YIELD_DECIMAL_PLACES = 4; const COUPON_YIELD_DECIMAL_PLACES = 4;
@@ -40,6 +49,7 @@ export class BondIncomeService {
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
) {} ) {}
async metrics( async metrics(
userId: string, userId: string,
uid: string, uid: string,
@@ -67,6 +77,7 @@ export class BondIncomeService {
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
}; };
} }
async coupons( async coupons(
userId: string, userId: string,
uid: string, uid: string,
@@ -74,8 +85,8 @@ export class BondIncomeService {
): Promise<BondCouponsResourceDto> { ): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(0, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all', observe);
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
@@ -85,8 +96,9 @@ export class BondIncomeService {
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
const position = portfolio.data const position = portfolio.data
? instrumentPosition(portfolio.data, uid, 'BOND') ? instrumentPosition(portfolio.data, uid, 'BOND')
@@ -135,13 +147,13 @@ export function bondCoupons(
const { from, to, today } = window; const { from, to, today } = window;
const quantity = const quantity =
position?.accounts.length && position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
? decimalFromString(position.quantity) ? decimalFromString(position.quantity)
: null; : null;
const coupons = events const coupons = events
.map((event) => { .map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perBond = money(event.payOneBond); const perBond = money(event.payOneBond);
const timing = !paymentDate const timing = !paymentDate
? ('undated' as const) ? ('undated' as const)
@@ -194,10 +206,10 @@ export function bondMetrics(
const effectiveYield = values.effectiveYield ?? null; const effectiveYield = values.effectiveYield ?? null;
const creditRating = values.creditRating ?? null; const creditRating = values.creditRating ?? null;
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const to = `${Number(today.slice(0, ISO_YEAR_LENGTH)) + 1}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const next = events const next = events
?.filter((event) => { ?.filter((event) => {
const date = timestamp(event.couponDate)?.slice(0, 10); const date = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH);
return date && date >= today && date <= to; return date && date >= today && date <= to;
}) })
.sort( .sort(
@@ -206,15 +218,15 @@ export function bondMetrics(
const coupon = money(next?.payOneBond); const coupon = money(next?.payOneBond);
const period = next?.couponPeriod; const period = next?.couponPeriod;
const couponPeriodDays = const couponPeriodDays =
period && Number.isInteger(period) && period > 0 ? period : null; period && Number.isInteger(period) && period > MIN_COUPON_PERIOD_EXCLUSIVE ? period : null;
const clean = quote?.cleanPrice; const clean = quote?.cleanPrice;
const couponYieldPercent = const couponYieldPercent =
coupon && coupon &&
couponPeriodDays && couponPeriodDays &&
clean && clean &&
coupon.currency === clean.currency && coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > 0n decimalFromString(clean.value).value > ZERO_AMOUNT
? formatDecimal( ? formatDecimal(
divide( divide(
multiply( multiply(
@@ -230,15 +242,15 @@ export function bondMetrics(
) )
: null; : null;
const couponType = const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
? next.couponType ? next.couponType
: instrument.floatingCouponFlag === true : instrument.floatingCouponFlag === true
? 'COUPON_TYPE_FLOATING' ? TbankCouponType.Floating
: null; : null;
return { return {
creditRating, creditRating,
coupon, coupon,
couponDate: timestamp(next?.couponDate)?.slice(0, 10) ?? null, couponDate: timestamp(next?.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
couponType, couponType,
couponPeriodDays, couponPeriodDays,
couponYieldPercent, couponYieldPercent,
@@ -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 { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { import {
add, add,
decimalFromString, decimalFromString,
@@ -13,15 +10,22 @@ import {
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '../../../../../integrations/tbank/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 { timestamp } from '../../application/instrument-market';
import { money } from '../infrastructure/bond-money.mapper';
import { import {
BondCandlesQueryDto, BondCandlesQueryDto,
BondQuoteDto, BondQuoteDto,
BondQuoteResourceDto, BondQuoteResourceDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper'; const ZERO_AMOUNT = 0n;
const PRICE_DECIMAL_PLACES = 9; const PRICE_DECIMAL_PLACES = 9;
@Injectable() @Injectable()
@@ -30,6 +34,7 @@ export class BondMarketService {
private readonly history: CandleService, private readonly history: CandleService,
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
) {} ) {}
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
@@ -49,6 +54,7 @@ export class BondMarketService {
meta: result.meta, meta: result.meta,
}; };
} }
candles( candles(
userId: string, userId: string,
uid: string, uid: string,
@@ -77,7 +83,7 @@ export function bondQuote(
const cleanPrice = const cleanPrice =
pricePercent !== null && pricePercent !== null &&
nominal && nominal &&
decimalFromString(nominal.value).value > 0n decimalFromString(nominal.value).value > ZERO_AMOUNT
? { ? {
currency: nominal.currency, currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
@@ -115,7 +121,7 @@ export function bondQuote(
last?.price && previous?.close last?.price && previous?.close
? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) ? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
: null, : null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
unavailable, unavailable,
observedAt, observedAt,
}; };
@@ -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 { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.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', () => ({ jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
@@ -1,6 +1,6 @@
import { formatDecimal } from '../../../../../common/decimal'; import { formatDecimal } from '../../../../../common/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) { export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase(); const currency = value?.currency?.toUpperCase();
@@ -1,4 +1,5 @@
import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; import type { Request } from 'express';
import { import {
Controller, Controller,
Get, Get,
@@ -9,24 +10,25 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; 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 { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service'; import { BondMarketService } from '../application/bond-market.service';
import { import {
BondCandlesResourceDto,
BondCandlesQueryDto, BondCandlesQueryDto,
BondCandlesResourceDto,
BondCouponsResourceDto, BondCouponsResourceDto,
BondResourceDto,
BondMetricsResourceDto, BondMetricsResourceDto,
BondPositionResourceDto, BondPositionResourceDto,
BondQuoteResourceDto, BondQuoteResourceDto,
BondResourceDto,
BondSearchQueryDto, BondSearchQueryDto,
BondSearchResourceDto, BondSearchResourceDto,
BondTickerParamsDto, BondTickerParamsDto,
} from './bond.dto'; } from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds') @ApiTags('bonds')
@ApiProblemResponses() @ApiProblemResponses()
@@ -39,6 +41,7 @@ export class BondController {
private readonly market: BondMarketService, private readonly market: BondMarketService,
private readonly income: BondIncomeService, private readonly income: BondIncomeService,
) {} ) {}
@Get(':uid/metrics') @Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsResourceDto }) @ApiOkResponse({ type: BondMetricsResourceDto })
metrics( metrics(
@@ -48,6 +51,7 @@ export class BondController {
) { ) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
} }
@Get() @Get()
@ApiOkResponse({ type: BondSearchResourceDto }) @ApiOkResponse({ type: BondSearchResourceDto })
search( search(
@@ -60,6 +64,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get('by-ticker/:ticker') @Get('by-ticker/:ticker')
@ApiOkResponse({ type: BondResourceDto }) @ApiOkResponse({ type: BondResourceDto })
getByTicker( getByTicker(
@@ -73,6 +78,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid') @Get(':uid')
@ApiOkResponse({ type: BondResourceDto }) @ApiOkResponse({ type: BondResourceDto })
get( get(
@@ -82,6 +88,7 @@ export class BondController {
) { ) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/quote') @Get(':uid/quote')
@ApiOkResponse({ type: BondQuoteResourceDto }) @ApiOkResponse({ type: BondQuoteResourceDto })
quote( quote(
@@ -91,6 +98,7 @@ export class BondController {
) { ) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe'); return this.market.quote(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/candles') @Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesResourceDto }) @ApiOkResponse({ type: BondCandlesResourceDto })
candles( candles(
@@ -105,6 +113,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid/position') @Get(':uid/position')
@ApiOkResponse({ type: BondPositionResourceDto }) @ApiOkResponse({ type: BondPositionResourceDto })
position( position(
@@ -118,6 +127,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsResourceDto }) @ApiOkResponse({ type: BondCouponsResourceDto })
coupons( coupons(

Some files were not shown because too many files have changed in this diff Show More