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

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