chore: strengthen backend eslint profile

This commit is contained in:
2026-09-28 11:04:18 +03:00
parent ca5dbcb382
commit 9b3558f96c
179 changed files with 4485 additions and 2716 deletions
+2 -2
View File
@@ -235,8 +235,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
Общий выбор symbolic values и числовых policies — Общий выбор symbolic values и числовых policies —
[Domain values](conventions/engineering.md#domain-values). [Domain values](conventions/engineering.md#domain-values).
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает `unknown` + runtime narrowing; ESLint запрещает explicit `any`
`any` нормой domain/application model. `declare` допустим для заполняемых извне в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
DTO fields, не для сокрытия неинициализированного owned state. DTO fields, не для сокрытия неинициализированного owned state.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+28
View File
@@ -105,6 +105,34 @@ Required checks настраиваются отдельно в Gitea: налич
runtime-валидацию. В обычном коде нужны инициализация или сужение типа runtime-валидацию. В обычном коде нужны инициализация или сужение типа
после проверки значения. после проверки значения.
## ESLint
`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts
и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные
автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела,
одинарные кавычки и точки с запятой; Prettier не используется.
Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked,
Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp,
Security, Perfectionist recommended-natural и Stylistic customize.
NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma,
dist и coverage исключены. DI imports сохраняют runtime metadata.
Исключения конфигурации: private backend не публикуется в npm;
TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain
unions. Автосортировка объектов, классов, модулей и декораторов отключена,
поскольку порядок может влиять на runtime. Остальные точечные исключения
объясняют false positives непосредственно в коде.
`eslint-suppressions.json` — штатный ESLint baseline только для существующих
SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals.
Это отложенный refactor, а не исправленные нарушения. Baseline ограничен
файлами и количеством сообщений; новые файлы и превышение количества блокируют
lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве
сообщений — это остаётся предметом review. При сокращении долга обновляйте
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
## SonarQube ## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
+2 -2
View File
@@ -1,4 +1,4 @@
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ['@commitlint/config-conventional'],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
}; };
+206
View File
@@ -0,0 +1,206 @@
{
"src/common/decimal.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/common/logging/request-logging.middleware.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/integrations/tbank/tbank.client.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/application/bank-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/income/income-period.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 4
},
"sonarjs/no-nested-conditional": {
"count": 4
},
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/bonds/application/bond-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/bonds/application/bond.spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/infrastructure/candle.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
}
},
"src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/stocks/application/stock-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/investment-resource.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 3
}
},
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": {
"count": 9
}
},
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/portfolio/application/forecast.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 7
}
},
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"test/sqlite-regression.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/sqlite.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
}
}
}
+77 -33
View File
@@ -1,31 +1,71 @@
// @ts-check // @ts-check
import nest from '@darraghor/eslint-plugin-nestjs-typed';
import eslint from '@eslint/js'; import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended'; import stylistic from '@stylistic/eslint-plugin';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import { importX } from 'eslint-plugin-import-x';
import jest from 'eslint-plugin-jest';
import node from 'eslint-plugin-n';
import perfectionist from 'eslint-plugin-perfectionist';
import regexp from 'eslint-plugin-regexp';
import security from 'eslint-plugin-security';
import sonarjs from 'eslint-plugin-sonarjs';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals'; import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
export default tseslint.config( const tests = ['**/*.spec.ts', 'test/**/*.ts'];
export default defineConfig(
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
{ {
ignores: ['eslint.config.mjs', 'dist/', 'generated/'], files: ['**/*.{ts,js,cjs,mjs}'],
}, extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, node.configs['flat/recommended'],
eslintPluginPrettierRecommended, importX.flatConfigs.recommended,
{ sonarjs.configs.recommended,
languageOptions: { regexp.configs['flat/recommended'],
globals: { security.configs.recommended,
...globals.node, perfectionist.configs['recommended-natural'],
...globals.jest, stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
}, ],
sourceType: 'commonjs', languageOptions: { globals: globals.node },
parserOptions: { linterOptions: { reportUnusedDisableDirectives: 'error' },
projectService: true, settings: {
tsconfigRootDir: import.meta.dirname, 'import-x/resolver-next': [createTypeScriptImportResolver()],
}, 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
},
rules: {
'curly': ['error', 'multi-line'],
// This private application is deployed as a whole, never published to npm.
'n/no-unpublished-import': 'off',
'n/no-unpublished-require': 'off',
// Decorator execution and object insertion order can be observable at runtime.
'perfectionist/sort-decorators': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-modules': 'off',
'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'after'],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
{ {
files: ['**/*.mjs'],
extends: [node.configs['flat/recommended-module']],
},
{
files: ['**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
languageOptions: {
sourceType: 'module',
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: { rules: {
// TypeScript resolves source imports; import-x checks them with the TS resolver.
'n/no-missing-import': 'off',
'no-magic-numbers': 'off', 'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [ '@typescript-eslint/no-magic-numbers': [
'error', 'error',
@@ -63,24 +103,28 @@ export default tseslint.config(
enforceConst: true, enforceConst: true,
}, },
], ],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [ 'no-restricted-syntax': ['error', {
'error', selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
{ message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
selector: }],
'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message:
'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
}, },
}, },
{ {
files: ['**/*.spec.ts', 'test/**/*.ts'], files: ['src/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' }, ignores: tests,
extends: [nest.configs.flatRecommended],
rules: {
// Domain string unions are validated with IsIn rather than runtime TS enums.
'@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }],
},
},
{
files: tests,
extends: [jest.configs['flat/recommended']],
rules: {
'@typescript-eslint/no-magic-numbers': 'off',
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
},
}, },
); );
+754 -215
View File
File diff suppressed because it is too large Load Diff
+19 -11
View File
@@ -11,14 +11,14 @@
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", "format": "npm run lint:fix",
"start": "nest start", "start": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint . --fix --max-warnings=0",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -30,10 +30,11 @@
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build", "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint" "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.4",
@@ -53,6 +54,7 @@
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"dotenv": "17.4.2",
"lru-cache": "11.5.2", "lru-cache": "11.5.2",
"nestjs-pino": "5.1.0", "nestjs-pino": "5.1.0",
"passport": "0.7.0", "passport": "0.7.0",
@@ -65,12 +67,13 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2", "@commitlint/config-conventional": "21.2.2",
"@eslint/eslintrc": "3.3.7", "@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0", "@nestjs/cli": "12.0.0",
"@nestjs/schematics": "12.0.1", "@nestjs/schematics": "12.0.1",
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.0.1",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.0",
"@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
@@ -79,12 +82,17 @@
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"eslint": "10.10.0", "eslint": "10.11.0",
"eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-prettier": "5.5.6", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest": "29.16.6",
"eslint-plugin-n": "18.4.0",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"globals": "17.12.0", "globals": "17.12.0",
"jest": "30.5.1", "jest": "30.5.1",
"prettier": "3.9.6",
"release-it": "21.0.2", "release-it": "21.0.2",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"supertest": "7.2.2", "supertest": "7.2.2",
@@ -93,7 +101,7 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.70.0" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"@nestjs/platform-express": { "@nestjs/platform-express": {
+11 -9
View File
@@ -1,10 +1,12 @@
const assert = require("node:assert/strict"); const assert = require('node:assert/strict');
const { execFileSync } = require("node:child_process"); const { execFileSync } = require('node:child_process');
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); const { version } = require('../package.json');
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another."); assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.');
+1
View File
@@ -15,6 +15,7 @@ Object.assign(process.env, {
}); });
const { NestFactory } = require('@nestjs/core'); const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js'); const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
+2 -1
View File
@@ -1,8 +1,8 @@
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino'; import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { Environment, validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
@@ -13,6 +13,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
import { OperationsModule } from './modules/investments/operations/operations.module'; import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module'; import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
+11 -5
View File
@@ -1,8 +1,10 @@
export type Decimal = { value: bigint; scale: number }; export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal { export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) // eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
throw new Error('Invalid decimal string'); throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-'); const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
return { return {
@@ -46,8 +48,12 @@ export function formatDecimal({ value, scale }: Decimal): string {
.toString() .toString()
.padStart(scale + 1, '0'); .padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0'; const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, ''); const rawFraction = digits.slice(-scale);
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - 1) === '0') fractionEnd -= 1;
const fraction = rawFraction.slice(0, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`;
} }
// Round half away from zero; callers choose presentation precision explicitly. // Round half away from zero; callers choose presentation precision explicitly.
@@ -55,7 +61,7 @@ export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero'); if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale); const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale); const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; const sign = (numerator < 0n) !== (denominator < 0n) ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator; const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator; const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale }; return { value: sign * ((n + d / 2n) / d), scale };
@@ -1,7 +1,8 @@
import { HttpStatus, applyDecorators } from '@nestjs/common'; import { applyDecorators, HttpStatus } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
ApiPropertyOptional,
ApiResponse, ApiResponse,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
@@ -13,8 +14,8 @@ export class ProblemDetailsDto {
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: 'UNAUTHORIZED' })
declare code: string; declare code: string;
@ApiProperty({ // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
required: false, @ApiPropertyOptional({
type: 'array', type: 'array',
items: { items: {
type: 'object', type: 'object',
@@ -23,7 +24,7 @@ export class ProblemDetailsDto {
}, },
example: [{ pointer: '#/email', detail: 'must be a valid email address' }], example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
}) })
errors?: { pointer: string; detail: string }[]; errors?: { detail: string; pointer: string }[];
} }
function problemResponse(status: number, description: string) { function problemResponse(status: number, description: string) {
@@ -38,7 +39,7 @@ function problemResponse(status: number, description: string) {
}); });
} }
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
@@ -6,7 +7,8 @@ import {
} from '../modules/banking/bank-cards/presentation/bank-card.dto'; } from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => { describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true }); const pipe = new ValidationPipe({
forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto }; const metadata = { type: 'body' as const, metatype: CredentialsDto };
@@ -1,4 +1,5 @@
import { ArgumentsHost } from '@nestjs/common'; import { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
+16 -13
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@@ -6,7 +8,6 @@ import {
HttpStatus, HttpStatus,
ValidationError, ValidationError,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
@@ -14,32 +15,33 @@ export class HttpExceptionFilter implements ExceptionFilter {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
const status = const status =
exception instanceof HttpException exception instanceof HttpException ?
? exception.getStatus() exception.getStatus() :
: HttpStatus.INTERNAL_SERVER_ERROR; HttpStatus.INTERNAL_SERVER_ERROR;
const payload = const payload =
exception instanceof HttpException ? exception.getResponse() : undefined; exception instanceof HttpException ? exception.getResponse() : undefined;
const isValidation = Array.isArray( const isValidation = Array.isArray(
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation const code = isValidation ?
? 'VALIDATION_ERROR' 'VALIDATION_ERROR' :
: this.codeFor(status, payload); this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code; response.locals ??= {};
response.locals.publicErrorCode = code;
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
.json({ .json({
status, status,
code, code,
...(isValidation ...(isValidation ?
? { {
errors: this.validationErrors( errors: this.validationErrors(
(payload as { message: ValidationError[] }).message, (payload as { message: ValidationError[] }).message,
), ),
} } :
: {}), {}),
}); });
} }
@@ -49,6 +51,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
if (typeof code === 'string') return code; if (typeof code === 'string') return code;
} }
return ( return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{ {
400: 'BAD_REQUEST', 400: 'BAD_REQUEST',
401: 'UNAUTHORIZED', 401: 'UNAUTHORIZED',
@@ -62,7 +65,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
private validationErrors( private validationErrors(
errors: ValidationError[], errors: ValidationError[],
prefix = '#', prefix = '#',
): { pointer: string; detail: string }[] { ): { detail: string; pointer: string }[] {
return errors.flatMap((error) => { return errors.flatMap((error) => {
if (!error || typeof error.property !== 'string') return []; if (!error || typeof error.property !== 'string') return [];
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`; const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
+1
View File
@@ -1,4 +1,5 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { RequestLoggingMiddleware } from './request-logging.middleware';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
@@ -2,10 +2,10 @@ import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks'; import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = { export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: string; brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
}; };
@Injectable() @Injectable()
@@ -1,4 +1,5 @@
import { EventEmitter } from 'node:events'; import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => { describe('RequestLoggingMiddleware', () => {
@@ -19,11 +20,11 @@ describe('RequestLoggingMiddleware', () => {
const next = jest.fn(); const next = jest.fn();
const request: { const request: {
method: string;
baseUrl: string; baseUrl: string;
route: { path: string };
header: jest.Mock<string>; header: jest.Mock<string>;
id?: string; id?: string;
method: string;
route: { path: string };
} = { } = {
method: 'GET', method: 'GET',
baseUrl: '/api/v1', baseUrl: '/api/v1',
@@ -1,7 +1,9 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
@@ -31,26 +33,26 @@ export class RequestLoggingMiddleware implements NestMiddleware {
const routePath = (routeRequest as { route?: { path?: unknown } }).route const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path; ?.path;
const route = const route =
typeof routePath === 'string' typeof routePath === 'string' ?
? `${request.baseUrl}${routePath}` `${request.baseUrl}${routePath}` :
: '<unmatched>'; '<unmatched>';
const context = { const context = {
method: request.method, method: request.method,
route, route,
status: response.statusCode, status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode ...(response.locals.publicErrorCode ?
? { code: response.locals.publicErrorCode as string } { code: response.locals.publicErrorCode as string } :
: {}), {}),
}; };
const level = const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
? 'error' 'error' :
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS || response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED || response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
? 'warn' 'warn' :
: 'info'; 'info';
this.logger.event(level, 'http.request.completed', context); this.logger.event(level, 'http.request.completed', context);
}); });
+17 -15
View File
@@ -1,27 +1,28 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service'; import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, string | number | boolean | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'service',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'operation',
'grpcCode',
'attempts', 'attempts',
'brokerageAccountId',
'clientRequestId',
'code',
'count', 'count',
'durationMs',
'environment',
'found', 'found',
'grpcCode',
'method',
'operation',
'requestId',
'route',
'service',
'status',
'userId',
]); ]);
@Injectable() @Injectable()
@@ -48,6 +49,7 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string, message: string,
context: LogContext = {}, context: LogContext = {},
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext({ ...this.requestContext.get(), ...context }),
message, message,
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types'; import type { Money } from './money.types';
export class MoneyDto implements Money { export class MoneyDto implements Money {
+7 -7
View File
@@ -7,12 +7,12 @@ export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
JWT_SECRET: string; JWT_SECRET: string;
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
PORT: number; PORT: number;
TOKEN_ENCRYPTION_KEY: string;
TBANK_CA_CERT_PATH?: string; TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string; TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number; TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; TOKEN_ENCRYPTION_KEY: string;
} }
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
@@ -71,7 +71,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
if ( if (
typeof tokenEncryptionKey !== 'string' || typeof tokenEncryptionKey !== 'string' ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) || !/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) ||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
@@ -92,7 +92,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
throw new Error('TBANK_CA_CERT_PATH must be a file path'); throw new Error('TBANK_CA_CERT_PATH must be a file path');
} }
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) { if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) {
throw new Error('LOG_LEVEL must be debug, info, warn, or error'); throw new Error('LOG_LEVEL must be debug, info, warn, or error');
} }
@@ -102,9 +102,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
JWT_SECRET: jwtSecret, JWT_SECRET: jwtSecret,
PORT: parsedPort, PORT: parsedPort,
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ?
? { TBANK_CA_CERT_PATH: tbankCaCertPath } { TBANK_CA_CERT_PATH: tbankCaCertPath } :
: {}), {}),
TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_GRPC_ENDPOINT: tbankEndpoint,
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
+10 -5
View File
@@ -199,8 +199,9 @@ describe('MoexClient', () => {
const url = new URL(input instanceof Request ? input.url : input); const url = new URL(input instanceof Request ? input.url : input);
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(url.searchParams.get('start') ?? ''); starts.push(url.searchParams.get('start') ?? '');
if (starts.length > 1) if (starts.length > 1) {
return Promise.reject(new Error('Repeated page')); return Promise.reject(new Error('Repeated page'));
}
} }
return Promise.resolve( return Promise.resolve(
Response.json({ Response.json({
@@ -228,13 +229,14 @@ describe('MoexClient', () => {
const start = Number(url.searchParams.get('start')); const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) { if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start)); starts.push(String(start));
if (starts.length > 2) if (starts.length > 2) {
return Promise.reject(new Error('Repeated page')); return Promise.reject(new Error('Repeated page'));
}
} }
return Promise.resolve( return Promise.resolve(
Response.json({ Response.json({
...emptyEvents, ...emptyEvents,
coupons: { 'coupons': {
columns: ['coupondate', 'value', 'faceunit'], columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']], data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
}, },
@@ -246,11 +248,13 @@ describe('MoexClient', () => {
); );
}); });
const result = new MoexClient().bondEvents('BOND'); const result = new MoexClient().bondEvents('BOND');
if (repeatIndex) if (repeatIndex) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).rejects.toMatchObject({ await expect(result).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' }, response: { code: 'MOEX_UNAVAILABLE' },
}); });
else } else {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).resolves.toEqual([ await expect(result).resolves.toEqual([
{ {
type: 'coupon', type: 'coupon',
@@ -265,6 +269,7 @@ describe('MoexClient', () => {
currency: 'RUB', currency: 'RUB',
}, },
]); ]);
}
expect(starts).toEqual(['0', '1']); expect(starts).toEqual(['0', '1']);
}, },
); );
+32 -20
View File
@@ -1,5 +1,5 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
@@ -7,18 +7,18 @@ const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
export type MoexBondEvent = export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string } | { currency: string; date: string; type: 'coupon'; value: string } |
| { type: 'maturity'; date: string; value: string; currency: string } { currency: string; date: string; type: 'maturity'; value: string } |
| { type: 'offer'; date: string }; { date: string; type: 'offer' };
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = { export type MoexBondYield = {
value: string;
pricePercent: string;
date: string; date: string;
dateType: string | null; dateType: null | string;
pricePercent: string;
tradeAt: string; tradeAt: string;
value: string;
}; };
@Injectable() @Injectable()
@@ -58,8 +58,9 @@ export class MoexClient {
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
!tradeAt || !tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
) ) {
return null; return null;
}
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null; if (!Number.isFinite(Date.parse(tradeIso))) return null;
return { return {
@@ -70,6 +71,7 @@ export class MoexClient {
tradeAt: new Date(tradeIso).toISOString(), tradeAt: new Date(tradeIso).toISOString(),
}; };
} }
async bondEvents(secid: string): Promise<MoexBondEvent[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
@@ -86,8 +88,9 @@ export class MoexClient {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
const currency = currencyText(row.faceunit); const currency = currencyText(row.faceunit);
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency }); events.push({ type: 'coupon', date, value, currency });
}
} }
for (const row of offers) { for (const row of offers) {
const date = text(row.offerdate); const date = text(row.offerdate);
@@ -99,8 +102,9 @@ export class MoexClient {
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT')); const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency }); events.push({ type: 'maturity', date, value, currency });
}
return events; return events;
} }
@@ -115,10 +119,11 @@ export class MoexClient {
const query = new URLSearchParams({ const query = new URLSearchParams({
'iss.meta': 'off', 'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`, 'iss.only': `${block},${block}.cursor`,
start: String(start), 'start': String(start),
}); });
const response = await this.request(`${baseUrl}/${path}?${query}`); const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows; if (!response) return rows;
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
const current = parseRows(response[block]); const current = parseRows(response[block]);
rows.push(...current); rows.push(...current);
if (!paginated) return rows; if (!paginated) return rows;
@@ -140,8 +145,9 @@ export class MoexClient {
pageSize === null || pageSize === null ||
pageSize === 0 || pageSize === 0 ||
current.length !== Math.min(pageSize, total - start) current.length !== Math.min(pageSize, total - start)
) ) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
}
if (index + pageSize >= total) return rows; if (index + pageSize >= total) return rows;
start = index + pageSize; start = index + pageSize;
} }
@@ -155,8 +161,9 @@ export class MoexClient {
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object') {
throw new Error('MOEX malformed response'); throw new Error('MOEX malformed response');
}
return body as IssResponse; return body as IssResponse;
} catch { } catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
@@ -165,35 +172,40 @@ export class MoexClient {
} }
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] { function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
}
const columns = block.columns; const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
}
return block.data.map((values) => { return block.data.map((values) => {
if (!Array.isArray(values) || values.length !== columns.length) if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
}
return Object.fromEntries( return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
columns.map((column, index) => [column.toLowerCase(), values[index]]), columns.map((column, index) => [column.toLowerCase(), values[index]]),
); );
}); });
} }
function text(value: unknown): string | null { function text(value: unknown): null | string {
return typeof value === 'string' && value.trim() ? value.trim() : null; return typeof value === 'string' && value.trim() ? value.trim() : null;
} }
function numberText(value: unknown): string | null { function numberText(value: unknown): null | string {
const result = typeof value === 'number' ? String(value) : text(value); const result = typeof value === 'number' ? String(value) : text(value);
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null; return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
} }
function currencyText(value: unknown): string | null { function currencyText(value: unknown): null | string {
const result = text(value)?.toUpperCase(); const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null); return result === 'SUR' ? 'RUB' : (result ?? null);
} }
function integer(value: unknown): number | null { function integer(value: unknown): null | number {
const result = Number(numberText(value) ?? NaN); const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null; return Number.isSafeInteger(result) && result >= 0 ? result : null;
} }
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
@Module({ @Module({
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44'; const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
@@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => {
); );
}, },
); );
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
'accepts the public rating grammar: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
},
);
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
'rejects invalid rating grades: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
},
);
it('bounds outage retries then recovers without retaining a false missing rating', async () => { it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest const fetchMock = jest
.spyOn(global, 'fetch') .spyOn(global, 'fetch')
+18 -10
View File
@@ -11,11 +11,11 @@ const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/'; const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = { export type NcrRating = {
agency: 'NCR'; agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string; date: string;
sourceUrl: string;
observedAt: string; observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
}; };
@Injectable() @Injectable()
@@ -33,8 +33,9 @@ export class NcrRatingsClient {
if (!pending) { if (!pending) {
pending = this.registry().catch(() => { pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating. // Bound outage traffic too; never present a failed download as no rating.
if (this.cache.get('registry') === pending) if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 }); this.cache.set('registry', pending, { ttl: 60_000 });
}
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
}); });
this.cache.set('registry', pending); this.cache.set('registry', pending);
@@ -57,10 +58,13 @@ export class NcrRatingsClient {
table.length !== 1 || table.length !== 1 ||
headings.length !== COLUMN_COUNT || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' || headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата' headings[DATE_COLUMN] !== 'Дата'
) ) {
throw new Error('NCR registry changed'); throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString(); const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>(); const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => { table.find('tbody tr').each((_, row) => {
@@ -73,23 +77,26 @@ export class NcrRatingsClient {
const withdrawn = raw.toLowerCase() === 'рейтинг отозван'; const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if ( if (
!withdrawn && !withdrawn &&
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test( !/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
raw, raw,
) )
) ) {
throw new Error('NCR rating changed'); throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(), cells.eq(DATE_COLUMN).text().trim(),
); );
const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
throw new Error('NCR identity missing'); throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if ( if (
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date new Date(date).toISOString().slice(0, 10) !== date
) ) {
throw new Error('NCR date invalid'); throw new Error('NCR date invalid');
}
const rating: NcrRating = { const rating: NcrRating = {
agency: 'NCR', agency: 'NCR',
value: withdrawn ? null : raw, value: withdrawn ? null : raw,
@@ -103,8 +110,9 @@ export class NcrRatingsClient {
previous && previous &&
previous.date === date && previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status) (previous.value !== rating.value || previous.status !== rating.status)
) ) {
throw new Error('NCR rating ambiguous'); throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating); if (!previous || previous.date < date) result.set(isin, rating);
}); });
if (!result.size) throw new Error('NCR registry empty'); if (!result.size) throw new Error('NCR registry empty');
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
@Module({ @Module({
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1'); ).toBe('1');
}); });
}); });
describe('decimal formatting', () => {
it.each([
[120000n, 4, '12'],
[-120030n, 4, '-12.003'],
[1n, 4, '0.0001'],
[0n, 4, '0'],
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
expect(formatDecimal({ value, scale })).toBe(expected);
});
it('preserves a long fraction with a trailing zero run', () => {
const significant = '1'.repeat(10_000);
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
});
});
+1 -1
View File
@@ -2,7 +2,7 @@ import { Decimal } from '../../common/decimal';
const NANO = 1_000_000_000n; const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number }; export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
@@ -1,24 +1,26 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { import type {
Prisma,
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client'; } from '../../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { tbankSourceTtlMs } from './tbank-source.policy'; import { tbankSourceTtlMs } from './tbank-source.policy';
export type MetadataIdentity = { export type MetadataIdentity = {
instrumentUid?: string | null; figi?: null | string;
figi?: string | null; instrumentUid?: null | string;
}; };
export type MetadataLookup = { export type MetadataLookup = {
type: 'uid' | 'figi'; data: null | TbankInstrument;
id: string; id: string;
data: TbankInstrument | null; type: 'figi' | 'uid';
}; };
const provider = 'tbank'; const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. // Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
@@ -36,15 +38,16 @@ export class InstrumentMetadataRepository {
async readMany(identities: MetadataIdentity[]) { async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [ const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim() ...(identity.instrumentUid?.trim() ?
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
: []), []),
...(identity.figi?.trim() ...(identity.figi?.trim() ?
? [{ identifierType: 'figi', identifier: identity.figi.trim() }] [{ identifierType: 'figi', identifier: identity.figi.trim() }] :
: []), []),
]); ]);
if (!identifiers.length) if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>(); return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = []; const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) { for (const identifierType of ['uid', 'figi']) {
@@ -59,7 +62,7 @@ export class InstrumentMetadataRepository {
let offset = 0; let offset = 0;
offset < values.length; offset < values.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
lookups.push( lookups.push(
...(await tx.tbankInstrumentLookup.findMany({ ...(await tx.tbankInstrumentLookup.findMany({
where: { where: {
@@ -71,6 +74,7 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
} }
const uids = [ const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])), ...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
@@ -80,7 +84,7 @@ export class InstrumentMetadataRepository {
let offset = 0; let offset = 0;
offset < uids.length; offset < uids.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
instruments.push( instruments.push(
...(await tx.tbankInstrument.findMany({ ...(await tx.tbankInstrument.findMany({
where: { where: {
@@ -89,6 +93,7 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
const byIdentifier = new Map( const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
); );
@@ -103,7 +108,7 @@ export class InstrumentMetadataRepository {
} }
async reserve( async reserve(
attemptOwner: { userId: string; revision: string }, attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity, identity: MetadataIdentity,
runId: string, runId: string,
active: ReadonlySet<string>, active: ReadonlySet<string>,
@@ -125,8 +130,9 @@ export class InstrumentMetadataRepository {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === runId && previous.processRunId === runId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId: runId, processRunId: runId,
@@ -183,12 +189,13 @@ export class InstrumentMetadataRepository {
['uid', uid], ['uid', uid],
['figi', figi], ['figi', figi],
] as const) { ] as const) {
if (id && uid && !aliases.has(`${type}:${id}`)) if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, { aliases.set(`${type}:${id}`, {
type, type,
id, id,
data: { ...result.data, uid }, data: { ...result.data, uid },
}); });
}
} }
} }
for (const result of aliases.values()) { for (const result of aliases.values()) {
@@ -196,8 +203,9 @@ export class InstrumentMetadataRepository {
const uid = const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID. // FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid) if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID'); throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) { if (instrument && uid) {
const key = { provider, uid }; const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({ const previous = await tx.tbankInstrument.findUnique({
@@ -220,9 +228,9 @@ export class InstrumentMetadataRepository {
update: { update: {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) increment: isDeepStrictEqual(previous?.metadata, payload) ?
? 0 0 :
: 1, 1,
}, },
}, },
}); });
@@ -243,9 +251,9 @@ export class InstrumentMetadataRepository {
matchedUids, matchedUids,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
? 0 0 :
: 1, 1,
}, },
}, },
}); });
@@ -266,7 +274,7 @@ export class InstrumentMetadataRepository {
errorCode: success ? null : 'TBANK_UNAVAILABLE', errorCode: success ? null : 'TBANK_UNAVAILABLE',
}, },
}); });
if (success) if (success) {
await this.prisma.tbankSyncWork.updateMany({ await this.prisma.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
@@ -275,6 +283,7 @@ export class InstrumentMetadataRepository {
}, },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
}); });
}
} }
} }
@@ -283,7 +292,7 @@ function selectMetadata(
lookups: ReadonlyMap<string, MetadataLookupRow>, lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>, instruments: ReadonlyMap<string, MetadataRow>,
) { ) {
const resolve = (type: string, id: string | null | undefined) => { const resolve = (type: string, id: null | string | undefined) => {
const lookup = lookups.get(`${type}:${id?.trim()}`); const lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined; const instrument = uid ? instruments.get(uid) : undefined;
@@ -307,6 +316,7 @@ function selectMetadata(
complete, complete,
fresh: fresh:
complete && complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null && fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata, Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
@@ -1,23 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import { import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { TbankClient, type TbankInstrument } from './tbank.client'; import { TbankClient, type TbankInstrument } from './tbank.client';
import {
InstrumentMetadataRepository,
metadataKey,
type MetadataIdentity,
type MetadataLookup,
} from './instrument-metadata.repository';
export { metadataKey } from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository';
type Producer = { type Producer = {
identity: MetadataIdentity;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
identity: MetadataIdentity;
observers: TbankSyncAttempt[]; observers: TbankSyncAttempt[];
}; };
@Injectable() @Injectable()
@@ -49,8 +50,9 @@ export class InstrumentMetadataService {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
if (revision && connection.revision !== revision) return null; if (revision && connection.revision !== revision) return null;
const data = (await this.savedMany([identity])).get(metadataKey(identity)); const data = (await this.savedMany([identity])).get(metadataKey(identity));
if (!observe && !data?.fresh) if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity); await this.request(userId, connection.revision, identity);
}
return { return {
...data, ...data,
work: await this.repository.work(userId, connection.revision, identity), work: await this.repository.work(userId, connection.revision, identity),
@@ -119,7 +121,7 @@ export class InstrumentMetadataService {
let useful = false; let useful = false;
let uidFailed = false; let uidFailed = false;
if (identity.instrumentUid) { if (identity.instrumentUid) {
let data: TbankInstrument | null | undefined; let data: null | TbankInstrument | undefined;
try { try {
data = await this.tbank.getInstrumentByUid( data = await this.tbank.getInstrumentByUid(
token, token,
@@ -130,29 +132,37 @@ export class InstrumentMetadataService {
!identity.figi || !identity.figi ||
(error instanceof Error && (error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN') error.message === 'INVALID_TBANK_TOKEN')
) ) {
throw error; throw error;
}
uidFailed = true; uidFailed = true;
} }
if (data?.uid && data.uid !== identity.instrumentUid) if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch'); throw new Error('Metadata UID mismatch');
if (data !== undefined) }
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi) if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
} }
if (!useful && identity.figi) { if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi( const data = await this.tbank.getInstrumentByFigi(
token, token,
identity.figi, identity.figi,
); );
if (data?.figi && data.figi !== identity.figi) if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch'); throw new Error('Metadata FIGI mismatch');
if (data && !data.name?.trim() && !data.ticker?.trim()) }
if (data && !data.name?.trim() && !data.ticker?.trim()) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
if (data === undefined || (uidFailed && !data)) }
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
if (data === undefined || (uidFailed && !data)) {
throw new Error('Metadata source unavailable'); throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data }); results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too; // A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup. // a failed UID RPC is never persisted as a negative lookup.
@@ -160,8 +170,9 @@ export class InstrumentMetadataService {
uidFailed && uidFailed &&
data?.uid === identity.instrumentUid && data?.uid === identity.instrumentUid &&
identity.instrumentUid identity.instrumentUid
) ) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
} }
if ( if (
!results.length || !results.length ||
@@ -170,8 +181,9 @@ export class InstrumentMetadataService {
(result) => (result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(), result.data?.name?.trim() || result.data?.ticker?.trim(),
)) ))
) ) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
results, results,
@@ -179,12 +191,13 @@ export class InstrumentMetadataService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
else } else {
this.logger.event('warn', 'instrument-metadata.failed', { this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE', code: 'TBANK_UNAVAILABLE',
}); });
}
} }
// Remove the producer before awaiting finalization: a new request cannot // Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained. // attach to a producer whose observer list has already been drained.
+25 -15
View File
@@ -1,4 +1,3 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
@@ -6,16 +5,19 @@ import {
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import type { TbankAccount } from './tbank.client'; import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
export type TbankSyncAttempt = { export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string; attemptId: string;
id: string;
revision: string;
userId: string;
}; };
@Injectable() @Injectable()
@@ -48,8 +50,9 @@ export class TbankAccessService {
connection.membershipAt, connection.membershipAt,
new Date(), new Date(),
) )
) ) {
return null; return null;
}
const scope = { userId, revision, scopeKey: 'membership' }; const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
@@ -58,8 +61,9 @@ export class TbankAccessService {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId, processRunId,
@@ -100,7 +104,7 @@ export class TbankAccessService {
return result.count === 1; return result.count === 1;
} }
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> { async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
const work = await this.prisma.tbankSyncWork.findFirst({ const work = await this.prisma.tbankSyncWork.findFirst({
where: { where: {
...attempt, ...attempt,
@@ -119,8 +123,9 @@ export class TbankAccessService {
completeWork = false, completeWork = false,
): Promise<boolean> { ): Promise<boolean> {
const supported = supportedTbankAccounts(accounts); const supported = supportedTbankAccounts(accounts);
if (new Set(supported.map(({ id }) => id)).size !== supported.length) if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
throw new Error('Duplicate T-Bank account identity'); throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and // Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below. // attempt supersession cannot interleave with the publication below.
@@ -146,6 +151,7 @@ export class TbankAccessService {
const changed = const changed =
previous.length !== next.length || previous.length !== next.length ||
previous.some((account, index) => { previous.some((account, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
const incoming = next[index]; const incoming = next[index];
return ( return (
account.accountId !== incoming.accountId || account.accountId !== incoming.accountId ||
@@ -181,11 +187,12 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? 1 : 0 }, membershipRevision: { increment: changed ? 1 : 0 },
}, },
}); });
if (completeWork) if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } }, data: { state: 'complete', progress: { accounts: supported.length } },
}); });
}
return true; return true;
}); });
} }
@@ -224,17 +231,20 @@ export class TbankAccessService {
accounts: { orderBy: { upstreamOrder: 'asc' } }, accounts: { orderBy: { upstreamOrder: 'asc' } },
}, },
}); });
if (!connection) if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken) }
if (connection.invalidToken) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
if ( if (
connection.membershipAt && connection.membershipAt &&
accountId && accountId &&
accountId !== 'all' && accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId) !connection.accounts.some((account) => account.accountId === accountId)
) ) {
throw new NotFoundException(); throw new NotFoundException();
}
return connection; return connection;
} }
} }
@@ -18,8 +18,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = { export type SupportedTbankAccount = {
id: string; id: string;
name: string; name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type']; type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
}; };
export function supportedTbankAccounts( export function supportedTbankAccounts(
@@ -39,7 +39,7 @@ export function supportedTbankAccounts(
}); });
} }
function openedOn(seconds: string | number | undefined): string | null { function openedOn(seconds: number | string | undefined): null | string {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; if (Number.isNaN(date.valueOf())) return null;
+23 -23
View File
@@ -6,23 +6,23 @@ import type {
} from './tbank.client'; } from './tbank.client';
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
uid?: string; apiTradeAvailableFlag?: boolean;
figi?: string;
assetUid?: string; assetUid?: string;
classCode?: string; classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean;
currency?: string; currency?: string;
shareType?: string; figi?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
}; };
export type TbankCandle = { export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal; close?: TbankDecimal;
isComplete?: boolean; isComplete?: boolean;
time?: TbankTimestamp;
}; };
export type TbankLastPrice = { export type TbankLastPrice = {
instrumentUid?: string; instrumentUid?: string;
@@ -32,38 +32,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = { export type TbankFundamentals = {
assetUid?: string; assetUid?: string;
currency?: string; currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number; marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number; peRatioTtm?: number;
priceToBookTtm?: number; priceToBookTtm?: number;
revenueTtm?: number; revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number; roe?: number;
totalDebtMrq?: number; totalDebtMrq?: number;
netDebtToEbitda?: number;
}; };
export type TbankBond = Omit<TbankShare, 'shareType'> & { export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney; aciValue?: TbankMoney;
maturityDate?: TbankTimestamp; amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp; callDate?: TbankTimestamp;
couponQuantityPerYear?: number; couponQuantityPerYear?: number;
floatingCouponFlag?: boolean; floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean; perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string; riskLevel?: string;
subordinatedFlag?: boolean;
}; };
export type TbankCoupon = { export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp; couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp; fixDate?: TbankTimestamp;
payOneBond?: TbankMoney; payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
}; };
@@ -1,5 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
@@ -8,6 +8,7 @@ describe('request-driven source freshness', () => {
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
const fetchedAt = new Date('2026-09-01Z'); const fetchedAt = new Date('2026-09-01Z');
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
const ttl = tbankSourceTtlMs[source]; const ttl = tbankSourceTtlMs[source];
expect( expect(
tbankSourceNeedsRefresh( tbankSourceNeedsRefresh(
@@ -21,6 +21,7 @@ export function tbankSourceNeedsRefresh(
return ( return (
force || force ||
!fetchedAt || !fetchedAt ||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source] now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
); );
} }
@@ -1,5 +1,6 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@@ -25,8 +26,9 @@ export class TbankTokenService {
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
}
throw error; throw error;
} }
} }
+11 -11
View File
@@ -1,9 +1,12 @@
// Public cross-module surface. Nest providers are exported by the matching module. // Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service'; export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
@@ -12,8 +15,15 @@ export {
type TbankLastPrice, type TbankLastPrice,
type TbankShare, type TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service'; export { TbankTokenService } from './tbank-token.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
TbankClient, TbankClient,
type TbankDividend, type TbankDividend,
@@ -25,13 +35,3 @@ export {
type TbankPositions, type TbankPositions,
type TbankTimestamp, type TbankTimestamp,
} from './tbank.client'; } from './tbank.client';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceTtlMs,
tbankSourceNeedsRefresh,
} from './tbank-source.policy';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
+16 -18
View File
@@ -1,5 +1,6 @@
import { status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -57,6 +58,7 @@ describe('TbankClient operations', () => {
const client = Object.create(TbankClient.prototype) as TbankClient; const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({}); const call = jest.fn().mockResolvedValue({});
(client as unknown as { call: jest.Mock }).call = call; (client as unknown as { call: jest.Mock }).call = call;
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await client[method]('token', 'uid', '2024-02-29', '2024-02-29'); await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
expect(call).toHaveBeenCalledWith('token', undefined, method, { expect(call).toHaveBeenCalledWith('token', undefined, method, {
instrumentId: 'uid', instrumentId: 'uid',
@@ -111,11 +113,10 @@ describe('TbankClient operations', () => {
ok: true, ok: true,
}); });
const failed = invoke('token', {}, 'getPortfolio', {}); const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual( await Promise.all([
expect.any(ServiceUnavailableException), expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
); jest.advanceTimersByTimeAsync(1000),
await jest.advanceTimersByTimeAsync(1000); ]);
await rejected;
expect(logger.event).toHaveBeenCalledWith( expect(logger.event).toHaveBeenCalledWith(
'debug', 'debug',
@@ -140,7 +141,7 @@ describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' }; const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient; let client: TbankClient;
let requestRpc: jest.Mock; let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock }; let queue: { run: jest.Mock; size: number };
let logger: { let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>; event: jest.Mock<void, [string, string, Record<string, unknown>]>;
}; };
@@ -234,12 +235,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying a saturated queue after three admission attempts', async () => { it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50; queue.size = 50;
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(6000),
]);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3); expect(logger.event).toHaveBeenCalledTimes(3);
@@ -253,12 +252,10 @@ describe('TbankClient metadata retries', () => {
it('stops retrying an unavailable upstream after three RPCs', async () => { it('stops retrying an unavailable upstream after three RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(6000),
]);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
@@ -282,6 +279,7 @@ describe('TbankClient metadata retries', () => {
async (method) => { async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await expect(client[method]('token', 'instrument')).resolves.toBeNull(); await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
+71 -62
View File
@@ -1,4 +1,3 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
@@ -17,8 +16,6 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type { import type {
TbankBond, TbankBond,
@@ -29,6 +26,10 @@ import type {
TbankShare, TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation';
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3; const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
@@ -42,78 +43,78 @@ type Unary<Request, Response> = (
request: Request, request: Request,
metadata: Metadata, metadata: Metadata,
options: { deadline: Date }, options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; type TbankClientService = Client & Record<string, Unary<never, never>>;
export type TbankDecimal = { units?: string | number; nano?: string | number }; export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
id: string; id: string;
type: string;
name: string; name: string;
openedDate?: { seconds?: string | number }; openedDate?: { seconds?: number | string };
type: string;
}; };
export type TbankPortfolio = { export type TbankPortfolio = {
positions?: { positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney; averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[]; }[];
}; };
export type TbankPositions = { money?: TbankMoney[] }; export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = { export type TbankInstrument = {
uid?: string; brand?: { logoName?: null | string };
figi?: string; figi?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string;
name?: string;
isin?: string; isin?: string;
brand?: { logoName?: string | null }; name?: string;
ticker?: string;
uid?: string;
}; };
export type TbankDividend = { export type TbankDividend = {
dividendNet?: TbankMoney; dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string; dividendType?: string;
lastBuyDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
recordDate?: TbankTimestamp;
}; };
export type TbankTimestamp = { seconds?: string | number; nanos?: number }; export type TbankTimestamp = { nanos?: number; seconds?: number | string };
export type TbankOperationItem = { export type TbankOperationItem = {
state?: string; accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string; cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp; date?: TbankTimestamp;
type?: string;
description?: string; description?: string;
figi?: string; figi?: string;
instrumentUid?: string; id?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string; instrumentUid?: string;
name?: string;
payment?: TbankMoney; payment?: TbankMoney;
price?: TbankMoney; price?: TbankMoney;
commission?: TbankMoney; quantity?: number | string;
accruedInt?: TbankMoney; quantityDone?: number | string;
quantity?: string | number; state?: string;
quantityDone?: string | number; ticker?: string;
type?: string;
}; };
export type TbankOperationsByCursorRequest = { export type TbankOperationsByCursorRequest = {
accountId: string; accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string; cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number; limit?: number;
state: 'OPERATION_STATE_EXECUTED'; state: 'OPERATION_STATE_EXECUTED';
to?: TbankTimestamp;
}; };
export type TbankOperationsByCursorResponse = { export type TbankOperationsByCursorResponse = {
hasNext?: boolean; hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[]; items?: TbankOperationItem[];
nextCursor?: string;
}; };
type TbankNamespace = { type TbankNamespace = {
tinkoff: { tinkoff: {
@@ -197,12 +198,14 @@ export class TbankClient {
loadPackageDefinition(definition) as unknown as TbankNamespace loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath const credentials = caPath ?
? ChannelCredentials.createSsl(readFileSync(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(); ChannelCredentials.createSsl(readFileSync(caPath)) :
ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new ( services[name] as new (
address: string, address: string,
credentials: ChannelCredentials, credentials: ChannelCredentials,
@@ -268,14 +271,14 @@ export class TbankClient {
getInstrumentByFigi( getInstrumentByFigi(
token: string, token: string,
figi: string, figi: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
} }
getInstrumentByUid( getInstrumentByUid(
token: string, token: string,
instrumentUid: string, instrumentUid: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
} }
@@ -320,7 +323,8 @@ export class TbankClient {
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
getBond(token: string, uid: string): Promise<TbankBond | null> {
getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
@@ -331,6 +335,7 @@ export class TbankClient {
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -349,7 +354,7 @@ export class TbankClient {
).then((response) => response.events ?? []); ).then((response) => response.events ?? []);
} }
getShare(token: string, uid: string): Promise<TbankShare | null> { getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
@@ -361,7 +366,7 @@ export class TbankClient {
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetDescription(token: string, assetUid: string): Promise<string | null> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
this.instruments, this.instruments,
@@ -375,7 +380,7 @@ export class TbankClient {
getFundamentals( getFundamentals(
token: string, token: string,
assetUid: string, assetUid: string,
): Promise<TbankFundamentals | null> { ): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>( return this.call<{ fundamentals?: TbankFundamentals[] }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
@@ -399,13 +404,13 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).then((response) => response.lastPrices ?? []);
} }
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> { getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then( return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null, (prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
); );
} }
getTradingStatus(token: string, uid: string): Promise<string | null> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
this.marketData, this.marketData,
@@ -442,9 +447,9 @@ export class TbankClient {
private async getInstrument( private async getInstrument(
token: string, token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
id: string, id: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
try { try {
const response = await this.call<{ instrument?: TbankInstrument }>( const response = await this.call<{ instrument?: TbankInstrument }>(
token, token,
@@ -471,8 +476,9 @@ export class TbankClient {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
if (queueFull) if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
const response = await this.queue.run(() => const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request), this.request<Response>(token, client, method, request),
); );
@@ -484,15 +490,15 @@ export class TbankClient {
return response; return response;
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull const failureContext = queueFull ?
? { code: 'TBANK_UNAVAILABLE' } { code: 'TBANK_UNAVAILABLE' } :
: { grpcCode }; { grpcCode };
const retryable = queueFull const retryable = queueFull ?
? metadataCall metadataCall :
: [ [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode); ].includes(grpcCode);
if (retryable && attempts < maxAttempts) { if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
@@ -504,9 +510,9 @@ export class TbankClient {
await new Promise((resolve) => await new Promise((resolve) =>
setTimeout( setTimeout(
resolve, resolve,
metadataCall metadataCall ?
? attempts * METADATA_RETRY_DELAY_MS attempts * METADATA_RETRY_DELAY_MS :
: RPC_RETRY_DELAY_MS, RPC_RETRY_DELAY_MS,
), ),
); );
continue; continue;
@@ -518,15 +524,17 @@ export class TbankClient {
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].includes(grpcCode) [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) ) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN'); throw new Error('INVALID_TBANK_TOKEN');
}
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall) (method === 'shareBy' || metadataCall)
) ) {
throw new NotFoundException(); throw new NotFoundException();
}
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
} }
} }
@@ -542,6 +550,7 @@ export class TbankClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); metadata.set('Authorization', `Bearer ${token}`);
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
client[method]( client[method](
request as never, request as never,
metadata, metadata,
+2 -1
View File
@@ -11,8 +11,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); const decoded = Buffer.from(key, 'base64');
if (decoded.length !== KEY_SIZE_BYTES) if (decoded.length !== KEY_SIZE_BYTES) {
throw new Error('Invalid token encryption key'); throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankMembershipService } from './tbank-membership.service';
import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service'; import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
+5 -3
View File
@@ -3,18 +3,19 @@ import {
INestApplication, INestApplication,
ValidationPipe, ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger'; import { SwaggerModule } from '@nestjs/swagger';
import { API_PREFIX, createOpenApiDocument } from './openapi'; import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware'; import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service'; import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
import { API_PREFIX, createOpenApiDocument } from './openapi';
export function configureApp(app: INestApplication): void { export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService); const config = app.get<ConfigService<Environment, true>>(ConfigService);
@@ -32,6 +33,7 @@ export function configureApp(app: INestApplication): void {
app.use(requestLogging.use.bind(requestLogging)); app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidUnknownValues: true,
transform: true, transform: true,
whitelist: true, whitelist: true,
validationError: { target: false, value: false }, validationError: { target: false, value: false },
@@ -114,8 +114,8 @@ describe('AuthService', () => {
where, where,
data, data,
}: { }: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string }; data: { refreshTokenHash: string };
where: { id?: string; refreshTokenHash?: string };
}) => { }) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) { if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash; currentHash = data.refreshTokenHash;
+21 -12
View File
@@ -6,6 +6,7 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthUser, RefreshPayload } from './auth.types';
@@ -16,6 +17,7 @@ const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = const DUMMY_PASSWORD_HASH =
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6'; '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable() @Injectable()
@@ -28,12 +30,13 @@ export class AuthService {
async register( async register(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
if (existing) if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
}
let user: User; let user: User;
try { try {
@@ -45,8 +48,9 @@ export class AuthService {
}, },
}); });
} catch (error) { } catch (error) {
if (isUniqueConstraintError(error)) if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
}
throw error; throw error;
} }
return this.createSession(user); return this.createSession(user);
@@ -55,7 +59,7 @@ export class AuthService {
async login( async login(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
@@ -63,14 +67,15 @@ export class AuthService {
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
); );
if (!user || !valid) if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
}
return this.createSession(user); return this.createSession(user);
} }
async refresh( async refresh(
token: string, token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
let payload: RefreshPayload; let payload: RefreshPayload;
try { try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
@@ -79,8 +84,9 @@ export class AuthService {
} catch { } catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
} }
if (payload.type !== 'refresh') if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: payload.sid }, where: { id: payload.sid },
include: { user: true }, include: { user: true },
@@ -91,12 +97,14 @@ export class AuthService {
session.revokedAt || session.revokedAt ||
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.prisma.session.delete({ where: { id: session.id } });
}
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
} }
if (session.refreshTokenHash !== this.hash(token)) if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
return this.rotateSession(session, session.refreshTokenHash); return this.rotateSession(session, session.refreshTokenHash);
} }
@@ -138,7 +146,7 @@ export class AuthService {
private async createSession( private async createSession(
user: User, user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const session = await this.prisma.session.create({ const session = await this.prisma.session.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -153,7 +161,7 @@ export class AuthService {
private async rotateSession( private async rotateSession(
session: Session & { user: User }, session: Session & { user: User },
expectedRefreshTokenHash?: string, expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
const refreshToken = await this.jwt.signAsync( const refreshToken = await this.jwt.signAsync(
{ {
sub: session.userId, sub: session.userId,
@@ -172,8 +180,9 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data, data,
}); });
if (updated.count !== 1) if (updated.count !== 1) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
} else { } else {
await this.prisma.session.update({ where: { id: session.id }, data }); await this.prisma.session.update({ where: { id: session.id }, data });
} }
+5 -5
View File
@@ -1,17 +1,17 @@
export interface AuthUser { export interface AuthUser {
id: string;
email: string; email: string;
id: string;
} }
export interface AccessPayload { export interface AccessPayload {
sub: string;
sid: string; sid: string;
sub: string;
type: 'access'; type: 'access';
} }
export interface RefreshPayload { export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string; jti: string;
sid: string;
sub: string;
type: 'refresh';
} }
+3 -2
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@Module({ @Module({
imports: [ imports: [
@@ -2,6 +2,7 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { Environment } from '../../../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -17,8 +18,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
validate(payload: AccessPayload): AccessPayload { validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
}
return payload; return payload;
} }
} }
@@ -1,3 +1,5 @@
import type { Request, Response } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -17,11 +19,11 @@ import {
ApiProperty, ApiProperty,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto';
import { AuthService } from '../application/auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -115,9 +117,9 @@ export class AuthController {
private respond( private respond(
response: Response, response: Response,
result: { result: {
user: { id: string; email: string };
accessToken: string; accessToken: string;
refreshToken: string; refreshToken: string;
user: { email: string; id: string };
}, },
) { ) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
@@ -129,6 +131,7 @@ export class AuthController {
} }
private refreshToken(request: Request): string | undefined { private refreshToken(request: Request): string | undefined {
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
const token: unknown = request.cookies?.[REFRESH_COOKIE]; const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined; return typeof token === 'string' ? token : undefined;
} }
+3 -1
View File
@@ -28,9 +28,10 @@ class EmailConstraint {
character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE, character.charCodeAt(0) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must be a valid email address'; return 'must be a valid email address';
} }
@@ -44,6 +45,7 @@ class PasswordBytesConstraint {
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return 'must not exceed 72 UTF-8 bytes';
} }
@@ -1,5 +1,6 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service'; import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
const request = context const request = context
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.requestContext.set({ userId: request.user.sub });
}
return Boolean(allowed); return Boolean(allowed);
} }
} }
@@ -1,21 +1,22 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
import { import {
bankPaymentSystems, bankPaymentSystems,
BankPeriodQueryDto, BankPeriodQueryDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = { export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT', InvalidInput: 'BANK_INVALID_INPUT',
@@ -54,8 +55,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
!isCalendarDate(query.from) || !isCalendarDate(query.from) ||
!isCalendarDate(query.to) || !isCalendarDate(query.to) ||
query.from > query.to query.from > query.to
) ) {
return fail(); return fail();
}
return { from: query.from, to: query.to }; return { from: query.from, to: query.to };
} }
export function conflict( export function conflict(
@@ -93,8 +95,9 @@ export function currentDate(value: string) {
if (value > moscowToday()) fail(BankErrorCode.InvalidDate); if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
} }
export function name(value: string, max: number) { export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
fail(); fail();
}
return value.trim(); return value.trim();
} }
export function cardInput(dto: CreateBankCardDto) { export function cardInput(dto: CreateBankCardDto) {
@@ -104,8 +107,9 @@ export function cardInput(dto: CreateBankCardDto) {
if ( if (
dto.paymentSystem != null && dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem) !bankPaymentSystems.some((system) => system === dto.paymentSystem)
) ) {
fail(); fail();
}
return { return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, MAX_CARD_NAME_LENGTH), name: name(dto.name, MAX_CARD_NAME_LENGTH),
@@ -120,8 +124,9 @@ export function operationInput(dto: CreateBankOperationDto) {
dto.description != null && dto.description != null &&
(typeof dto.description !== 'string' || (typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
) ) {
fail(); fail();
}
return { return {
kind: dto.kind, kind: dto.kind,
cardId: dto.cardId, cardId: dto.cardId,
@@ -1,24 +1,24 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
BankCardDto, BankCardDto,
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { import {
cardInput,
BankErrorCode, BankErrorCode,
cardInput,
conflict, conflict,
currentDate, currentDate,
fail, fail,
fingerprint, fingerprint,
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
@@ -26,6 +26,7 @@ export class BankCardsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where: { where: {
@@ -47,6 +48,7 @@ export class BankCardsService {
archivedAt: card.archivedAt?.toISOString() ?? null, archivedAt: card.archivedAt?.toISOString() ?? null,
}; };
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
Promise.all( Promise.all(
@@ -59,11 +61,13 @@ export class BankCardsService {
), ),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
createCard(userId: string, dto: CreateBankCardDto) { createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
@@ -71,8 +75,10 @@ export class BankCardsService {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash) // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
if (existing.createFingerprint !== hash) {
conflict(BankErrorCode.IntentConflict); conflict(BankErrorCode.IntentConflict);
}
return this.cardView(tx, existing); return this.cardView(tx, existing);
} }
currentDate(input.openingDate); currentDate(input.openingDate);
@@ -85,6 +91,7 @@ export class BankCardsService {
); );
}); });
} }
updateCard(userId: string, id: string, dto: UpdateBankCardDto) { updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id); const previous = await this.repository.card(tx, userId, id);
@@ -93,17 +100,17 @@ export class BankCardsService {
id, id,
name: dto.name ?? previous.name, name: dto.name ?? previous.name,
bankName: bankName:
dto.bankName === undefined dto.bankName === undefined ?
? (previous.bankName ?? undefined) (previous.bankName ?? undefined) :
: (dto.bankName ?? undefined), (dto.bankName ?? undefined),
lastFour: lastFour:
dto.lastFour === undefined dto.lastFour === undefined ?
? (previous.lastFour ?? undefined) (previous.lastFour ?? undefined) :
: (dto.lastFour ?? undefined), (dto.lastFour ?? undefined),
paymentSystem: paymentSystem:
dto.paymentSystem === undefined dto.paymentSystem === undefined ?
? previous.paymentSystem previous.paymentSystem :
: dto.paymentSystem, dto.paymentSystem,
openingBalance: openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate, openingDate: dto.openingDate ?? previous.openingDate,
@@ -111,8 +118,9 @@ export class BankCardsService {
const financial = const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate; input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) if (financial && previous.archivedAt) {
conflict(BankErrorCode.CardArchived); conflict(BankErrorCode.CardArchived);
}
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await tx.bankCardOperation.findFirst({
@@ -123,20 +131,21 @@ export class BankCardsService {
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
}, },
}) })
) ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
}
const changed = await tx.bankCard.updateMany({ const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version }, where: { id, userId, version: dto.version },
data: { data: {
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
...(dto.archived === undefined ...(dto.archived === undefined ?
? {} {} :
: { {
archivedAt: dto.archived archivedAt: dto.archived ?
? (previous.archivedAt ?? new Date()) (previous.archivedAt ?? new Date()) :
: null, null,
}), }),
}, },
}); });
@@ -1,20 +1,21 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import { import {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
BankOperationDto, BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto, BankOperationsQueryDto,
BankPageQueryDto, BankPageQueryDto,
CreateBankOperationDto, CreateBankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
UpdateBankOperationDto, UpdateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
@@ -25,7 +26,6 @@ import {
operationInput, operationInput,
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
@@ -33,34 +33,35 @@ export class BankOperationsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
row: BankCardOperation, row: BankCardOperation,
): Promise<BankOperationDto> { ): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId); const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId const target = row.targetCardId ?
? await this.repository.card(tx, row.userId, row.targetCardId) await this.repository.card(tx, row.userId, row.targetCardId) :
: null; null;
const source = row.originalExpenseId const source = row.originalExpenseId ?
? await this.repository.operation(tx, row.userId, row.originalExpenseId) await this.repository.operation(tx, row.userId, row.originalExpenseId) :
: null; null;
const categoryId = source?.categoryId ?? row.categoryId; const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId const category = categoryId ?
? await tx.expenseCategory.findFirst({ await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId }, where: { id: categoryId, userId: row.userId },
}) }) :
: null; null;
const refunds = const refunds =
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source ?
? await tx.bankCardOperation.findMany({ await tx.bankCardOperation.findMany({
where: { where: {
userId: row.userId, userId: row.userId,
originalExpenseId: source?.id ?? row.id, originalExpenseId: source?.id ?? row.id,
deletedAt: null, deletedAt: null,
...(source ? { id: { not: row.id } } : {}), ...(source ? { id: { not: row.id } } : {}),
}, },
}) }) :
: []; [];
return { return {
id: row.id, id: row.id,
kind: row.kind as BankOperationDto['kind'], kind: row.kind as BankOperationDto['kind'],
@@ -73,18 +74,19 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId, originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks), amount: rubMoney(row.amountKopecks),
availableRefund: availableRefund:
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source ?
? rubMoney( rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
) ) :
: null, null,
date: row.date, date: row.date,
description: row.description, description: row.description,
version: row.version, version: row.version,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
private async validateOperation( private async validateOperation(
tx: Tx, tx: Tx,
userId: string, userId: string,
@@ -102,23 +104,26 @@ export class BankOperationsService {
if ( if (
(id === input.cardId || id === input.targetCardId) && (id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate input.date < card.openingDate
) ) {
fail(BankErrorCode.InvalidDate); fail(BankErrorCode.InvalidDate);
}
} }
if ( if (
old && old &&
(old.kind !== input.kind || (old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId) old.originalExpenseId !== input.originalExpenseId)
) ) {
fail(BankErrorCode.ImmutableOperation); fail(BankErrorCode.ImmutableOperation);
}
if (input.kind === BankOperationKind.Transfer) { if (input.kind === BankOperationKind.Transfer) {
if ( if (
!input.targetCardId || !input.targetCardId ||
input.targetCardId === input.cardId || input.targetCardId === input.cardId ||
input.categoryId || input.categoryId ||
input.originalExpenseId input.originalExpenseId
) ) {
fail(); fail();
}
} else if (input.targetCardId) fail(); } else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail(); if (input.originalExpenseId) fail();
@@ -133,11 +138,13 @@ export class BankOperationsService {
!(await tx.expenseCategory.findFirst({ !(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId }, where: { userId, id: input.categoryId },
})) }))
) ) {
missing(); missing();
}
} else if (input.categoryId) fail(); } else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail(); fail();
}
if (input.kind === BankOperationKind.Refund) { if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail(); if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
@@ -149,8 +156,9 @@ export class BankOperationsService {
source.deletedAt || source.deletedAt ||
source.kind !== BankOperationKind.Expense || source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId source.cardId !== input.cardId
) ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
}
if ( if (
( (
await tx.bankCardOperation.updateMany({ await tx.bankCardOperation.updateMany({
@@ -163,8 +171,9 @@ export class BankOperationsService {
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== 1
) ) {
conflict(); conflict();
}
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
where: { where: {
userId, userId,
@@ -176,10 +185,11 @@ export class BankOperationsService {
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks > input.amountKopecks >
source.amountKopecks source.amountKopecks
) ) {
conflict(BankErrorCode.RefundLimit); conflict(BankErrorCode.RefundLimit);
}
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
@@ -190,11 +200,13 @@ export class BankOperationsService {
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) || refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks) input.amountKopecks)
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
} }
} }
createOperation(userId: string, dto: CreateBankOperationDto) { createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
@@ -204,10 +216,13 @@ export class BankOperationsService {
}); });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash) // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
if (existing.createFingerprint !== hash) {
conflict(BankErrorCode.IntentConflict); conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt) }
if (existing.deletedAt) {
throw new GoneException({ code: BankErrorCode.OperationDeleted }); throw new GoneException({ code: BankErrorCode.OperationDeleted });
}
return this.operationView(tx, existing); return this.operationView(tx, existing);
} }
const input = { ...normalized }; const input = { ...normalized };
@@ -220,6 +235,7 @@ export class BankOperationsService {
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
@@ -227,6 +243,7 @@ export class BankOperationsService {
return this.operationView(tx, row); return this.operationView(tx, row);
}); });
} }
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const old = await this.repository.operation(tx, userId, id); const old = await this.repository.operation(tx, userId, id);
@@ -252,29 +269,33 @@ export class BankOperationsService {
data: { ...input, version: { increment: 1 } }, data: { ...input, version: { increment: 1 } },
}) })
).count !== 1 ).count !== 1
) ) {
conflict(); conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
); );
}); });
} }
deleteOperation(userId: string, id: string, version: number) { deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return; if (row.deletedAt) return;
if (row.version !== version) conflict(); if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true); await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId) if (row.targetCardId) {
await this.repository.card(tx, userId, row.targetCardId, true); await this.repository.card(tx, userId, row.targetCardId, true);
}
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null }, where: { userId, originalExpenseId: id, deletedAt: null },
})) }))
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
if (row.originalExpenseId) { if (row.originalExpenseId) {
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
@@ -288,8 +309,9 @@ export class BankOperationsService {
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== 1
) ) {
conflict(); conflict();
}
} }
if ( if (
( (
@@ -298,30 +320,32 @@ export class BankOperationsService {
data: { deletedAt: new Date(), version: { increment: 1 } }, data: { deletedAt: new Date(), version: { increment: 1 } },
}) })
).count !== 1 ).count !== 1
) ) {
conflict(); conflict();
}
}); });
} }
history( history(
userId: string, userId: string,
id: string, id: string,
query: BankOperationsQueryDto | BankPageQueryDto, query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false, expenses = false,
) { ) {
const period = expenses const period = expenses ?
? undefined undefined :
: periodBounds(query as BankOperationsQueryDto); periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const page = query.page ?? 1, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; page = query.page ?? 1;
const where: Prisma.BankCardOperationWhereInput = { const where: Prisma.BankCardOperationWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(period ...(period ?
? { date: { gte: period.from, lte: period.to } } { date: { gte: period.from, lte: period.to } } :
: { kind: BankOperationKind.Expense }), { kind: BankOperationKind.Expense }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -1,16 +1,17 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing, name } from './bank-card-input'; import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input';
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
async categories(userId: string) { async categories(userId: string) {
return ( return (
await this.prisma.expenseCategory.findMany({ await this.prisma.expenseCategory.findMany({
@@ -24,6 +25,7 @@ export class ExpenseCategoriesService {
version, version,
})); }));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -44,6 +46,7 @@ export class ExpenseCategoriesService {
}; };
}); });
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -60,8 +63,9 @@ export class ExpenseCategoriesService {
}, },
}) })
).count !== 1 ).count !== 1
) ) {
conflict(); conflict();
}
return { return {
id, id,
name: label, name: label,
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations'; import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { periodBounds } from './bank-card-input';
@Injectable() @Injectable()
export class GetBankSummaryUseCase { export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {} constructor(private readonly repository: BankCardsRepository) {}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { BankCardsController } from './presentation/bank-cards.controller';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsService } from './application/bank-cards.service'; import { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service'; import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service'; import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsController } from './presentation/bank-cards.controller';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule],
controllers: [BankCardsController], controllers: [BankCardsController],
@@ -22,10 +22,10 @@ export function bankBalance(
cardId: string, cardId: string,
opening: bigint, opening: bigint,
operations: Array<{ operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint; amountKopecks: bigint;
cardId: string;
kind: string;
targetCardId: null | string;
}>, }>,
): bigint { ): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories. // ponytail: O(N) history fold; optimize only after measuring growing histories.
@@ -35,19 +35,21 @@ export function bankBalance(
if ( if (
operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer operation.kind === BankOperationKind.Transfer
) ) {
balance -= operation.amountKopecks; balance -= operation.amountKopecks;
else if ( } else if (
operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
if ( if (
operation.kind === BankOperationKind.Transfer && operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId operation.targetCardId === cardId
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
return balance; return balance;
} }
@@ -55,17 +57,17 @@ export function bankBalance(
export function bankSummary( export function bankSummary(
period: { from: string; to: string }, period: { from: string; to: string },
operations: Array<{ operations: Array<{
kind: string;
date: string;
amountKopecks: bigint; amountKopecks: bigint;
categoryId: string | null; categoryId: null | string;
date: string;
kind: string;
}>, }>,
) { ) {
let expenses = 0n; let expenses = 0n;
let refunds = 0n; let refunds = 0n;
const categories = new Map< const categories = new Map<
string, string,
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint } { categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
>(); >();
// ponytail: O(N) history fold; optimize only after measuring growing histories. // ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) { for (const operation of operations) {
@@ -73,16 +75,17 @@ export function bankSummary(
if ( if (
operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund operation.kind !== BankOperationKind.Refund
) ) {
continue; continue;
}
const expense = const expense =
operation.kind === BankOperationKind.Expense operation.kind === BankOperationKind.Expense ?
? operation.amountKopecks operation.amountKopecks :
: 0n; 0n;
const refund = const refund =
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund ?
? operation.amountKopecks operation.amountKopecks :
: 0n; 0n;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
@@ -39,8 +39,9 @@ describe('RUB money', () => {
}); });
it('requires positive operation amounts, including at the maximum', () => { it('requires positive operation amounts, including at the maximum', () => {
for (const value of ['0', '-0.00', '-0.01']) for (const value of ['0', '-0.00', '-0.01']) {
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow(); expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
}
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect( expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
@@ -1,6 +1,7 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
const MIN_KOPECKS = -9_223_372_036_854_775_808n; const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n; const MAX_KOPECKS = 9_223_372_036_854_775_807n;
@@ -9,16 +10,20 @@ export function parseRubMoney(money: Money, positive = false): bigint {
money?.currency !== 'RUB' || money?.currency !== 'RUB' ||
typeof money.value !== 'string' || typeof money.value !== 'string' ||
money.value !== money.value.trim() money.value !== money.value.trim()
) ) {
throw new TypeError('Expected RUB money with a decimal string'); throw new TypeError('Expected RUB money with a decimal string');
}
const amount = decimalFromString(money.value); const amount = decimalFromString(money.value);
if (amount.scale > 2) if (amount.scale > 2) {
throw new RangeError('RUB money supports at most two decimal places'); throw new RangeError('RUB money supports at most two decimal places');
}
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale); const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
throw new RangeError('RUB money is outside the signed 64-bit range'); throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n) }
if (positive && kopecks <= 0n) {
throw new RangeError('Operation amount must be positive'); throw new RangeError('Operation amount must be positive');
}
return kopecks; return kopecks;
} }
@@ -1,14 +1,14 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client'; import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { BankOperationKind } from '../domain/bank-operation-kind';
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000; const TRANSACTION_TIMEOUT_MS = 15_000;
@@ -36,33 +36,37 @@ export class BankCardsRepository {
}); });
} catch (error) { } catch (error) {
const code = const code =
error instanceof Prisma.PrismaClientKnownRequestError error instanceof Prisma.PrismaClientKnownRequestError ?
? error.code error.code :
: ''; '';
const busy = const busy =
code === 'P2034' || code === 'P2034' ||
code === 'P2028' || code === 'P2028' ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
continue; continue;
}
if (busy) conflict(BankErrorCode.WriteConflict); if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
throw error; throw error;
} }
} }
} }
async card(tx: Tx, userId: string, id: string, active = false) { async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } }); const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing(); if (!card) return missing();
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card; return card;
} }
async operation(tx: Tx, userId: string, id: string) { async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing(); if (!row) return missing();
return row; return row;
} }
async seed(tx: Tx, userId: string) { async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) { for (const [index, label] of seeds.entries()) {
const seedKey = index === seeds.length - 1 ? 'other' : String(index); const seedKey = index === seeds.length - 1 ? 'other' : String(index);
@@ -70,7 +74,7 @@ export class BankCardsRepository {
!(await tx.expenseCategory.findUnique({ !(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } }, where: { userId_seedKey: { userId, seedKey } },
})) }))
) ) {
await tx.expenseCategory.create({ await tx.expenseCategory.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -80,8 +84,10 @@ export class BankCardsRepository {
nameKey: label.toLocaleLowerCase('ru-RU'), nameKey: label.toLocaleLowerCase('ru-RU'),
}, },
}); });
}
} }
} }
summaryData( summaryData(
userId: string, userId: string,
period: { from: string; to: string }, period: { from: string; to: string },
@@ -1,9 +1,3 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
@@ -28,7 +22,14 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import {
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
export const DEFAULT_BANK_PAGE_SIZE = 50; export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100; const MAX_BANK_PAGE_SIZE = 100;
@@ -41,6 +42,7 @@ export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString() @IsString()
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
@Matches(/^-?\d+(\.\d{1,2})?$/) @Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string; declare value: string;
} }
@@ -52,6 +54,7 @@ export class CreateBankCardDto {
@MinLength(1) @MinLength(1)
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string; declare name: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,
nullable: true, nullable: true,
@@ -61,20 +64,24 @@ export class CreateBankCardDto {
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null; bankName?: null | string;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional() @IsOptional()
@Matches(/^[0-9]{4}$/) @Matches(/^\d{4}$/)
lastFour?: string | null; lastFour?: null | string;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional() @IsOptional()
@IsIn(bankPaymentSystems) @IsIn(bankPaymentSystems)
paymentSystem?: string | null; paymentSystem?: null | string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto; declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@@ -106,28 +113,34 @@ export class CreateBankOperationDto {
@ApiProperty({ enum: bankOperationKinds }) @ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds) @IsIn(bankOperationKinds)
declare kind: BankOperationKind; declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
targetCardId?: string; targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
originalExpenseId?: string; originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare amount: MoneyDto; declare amount: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string; declare date: string;
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsString() @IsString()
@@ -153,6 +166,7 @@ export class BankPageQueryDto {
@IsInt() @IsInt()
@Min(1) @Min(1)
page?: number; page?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
minimum: 1, minimum: 1,
maximum: MAX_BANK_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE,
@@ -173,6 +187,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/) @Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string; month?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive start date; requires to.', description: 'Inclusive start date; requires to.',
@@ -180,6 +195,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string; from?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive end date; requires from.', description: 'Inclusive end date; requires from.',
@@ -215,16 +231,20 @@ export class BankCardDto {
@ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName: @ApiProperty({ type: String, nullable: true }) declare bankName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare lastFour: @ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null; null | string;
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) @ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null; declare paymentSystem: null | string;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt: @ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
} }
export class BankOperationDto { export class BankOperationDto {
@@ -233,27 +253,35 @@ export class BankOperationDto {
@ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId: @ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare targetCardName: @ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryId: @ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryName: @ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId: @ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null; null | string;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null; declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description: @ApiProperty({ type: String, nullable: true }) declare description:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string; @ApiProperty({ type: String }) declare createdAt: string;
} }
export class BankOperationsPageDto { export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true }) @ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[]; declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean; @ApiProperty({ type: Boolean }) declare hasNext: boolean;
@@ -269,7 +297,7 @@ export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string; @ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null; @ApiProperty({ type: String, nullable: true }) declare cardId: null | string;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto; @ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@@ -1,3 +1,5 @@
import type { Request } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -21,12 +23,16 @@ import {
ApiTags, ApiTags,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request } from 'express';
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator'; } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
import { import {
BankCardDto, BankCardDto,
BankCardsQueryDto, BankCardsQueryDto,
@@ -45,10 +51,6 @@ import {
UpdateBankOperationDto, UpdateBankOperationDto,
UpdateExpenseCategoryDto, UpdateExpenseCategoryDto,
} from './bank-card.dto'; } from './bank-card.dto';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
type AuthRequest = Request & { user: AccessPayload }; type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
@@ -82,26 +84,31 @@ export class BankCardsController {
private readonly expenseCategories: ExpenseCategoriesService, private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase, private readonly bankSummary: GetBankSummaryUseCase,
) {} ) {}
@Get() @Get()
@ApiOkResponse({ type: BankCardDto, isArray: true }) @ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true'); return this.cards.list(req.user.sub, query.includeArchived === 'true');
} }
@Post() @Post()
@ApiCreatedResponse({ type: BankCardDto }) @ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body); return this.cards.createCard(req.user.sub, body);
} }
@Get('categories') @Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) { categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub); return this.expenseCategories.categories(req.user.sub);
} }
@Post('categories') @Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto }) @ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name); return this.expenseCategories.createCategory(req.user.sub, body.name);
} }
@Patch('categories/:id') @Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto }) @ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory( updateCategory(
@@ -116,11 +123,13 @@ export class BankCardsController {
body.version, body.version,
); );
} }
@Get('summary') @Get('summary')
@ApiOkResponse({ type: BankSummaryDto }) @ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query); return this.bankSummary.execute(req.user.sub, query);
} }
@Post('operations') @Post('operations')
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
createOperation( createOperation(
@@ -129,11 +138,13 @@ export class BankCardsController {
) { ) {
return this.operations.createOperation(req.user.sub, body); return this.operations.createOperation(req.user.sub, body);
} }
@Get('operations/:id') @Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) { operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id); return this.operations.getOperation(req.user.sub, id);
} }
@Patch('operations/:id') @Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
updateOperation( updateOperation(
@@ -143,6 +154,7 @@ export class BankCardsController {
) { ) {
return this.operations.updateOperation(req.user.sub, id, body); return this.operations.updateOperation(req.user.sub, id, body);
} }
@Delete('operations/:id') @Delete('operations/:id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
@@ -153,6 +165,7 @@ export class BankCardsController {
) { ) {
return this.operations.deleteOperation(req.user.sub, id, query.version); return this.operations.deleteOperation(req.user.sub, id, query.version);
} }
@Get(':id/operations') @Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
history( history(
@@ -162,6 +175,7 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query); return this.operations.history(req.user.sub, id, query);
} }
@Get(':id/expenses') @Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
expenses( expenses(
@@ -171,11 +185,13 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query, true); return this.operations.history(req.user.sub, id, query, true);
} }
@Get(':id') @Get(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) { get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id); return this.cards.get(req.user.sub, id);
} }
@Patch(':id') @Patch(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
update( update(
@@ -1,6 +1,7 @@
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -40,15 +41,18 @@ export class BrokerageAccountDto {
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' }) @ApiProperty({ type: String, nullable: true, format: 'date' })
declare openedOn: string | null; declare openedOn: null | string;
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field.
@ApiProperty({ type: PortfolioSummaryDto, nullable: true }) @ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null; portfolioSummary?: null | PortfolioSummaryDto;
} }
export class BrokerageAccountsResourceDto { export class BrokerageAccountsResourceDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true }) @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
declare data: BrokerageAccountDto[] | null; declare data: BrokerageAccountDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -1,9 +1,11 @@
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,7 +1,8 @@
import { PortfolioModule } from '../portfolio/portfolio.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,8 +1,8 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { BrokerageAccountsService } from './brokerage-accounts.service';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageAccountsService } from './brokerage-accounts.service';
jest.mock('../portfolio/application/portfolio.service', () => ({ jest.mock('../portfolio/application/portfolio.service', () => ({
PortfolioService: class PortfolioService {}, PortfolioService: class PortfolioService {},
@@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PortfolioService } from '../portfolio/portfolio.api';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { BrokerageAccountDto } from './brokerage-account.dto';
@Injectable() @Injectable()
@@ -11,15 +12,15 @@ export class BrokerageAccountsService {
async list(userId: string, observe = false) { async list(userId: string, observe = false) {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all', observe);
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' saved.membership.availability === 'missing' ?
? null null :
: saved.accounts.map((account) => ({ saved.accounts.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
type: account.type as BrokerageAccountDto['type'], type: account.type as BrokerageAccountDto['type'],
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot portfolioSummary: account.snapshot ?
? { {
totalsByCurrency: account.snapshot.totalsByCurrency, totalsByCurrency: account.snapshot.totalsByCurrency,
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
@@ -27,8 +28,8 @@ export class BrokerageAccountsService {
bondPositionCount: account.snapshot.positions.filter( bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND', (position) => position.type === 'BOND',
).length, ).length,
} } :
: null, null,
})); }));
return { return {
data, data,
@@ -46,18 +47,18 @@ export class BrokerageAccountsService {
) )
.digest('base64url'), .digest('base64url'),
availability: availability:
data === null data === null ?
? 'missing' 'missing' :
: saved.accounts.some((account) => !account.snapshot) saved.accounts.some((account) => !account.snapshot) ?
? 'partial' 'partial' :
: 'complete', 'complete',
fetchedAt: saved.membership.lastSuccessfulAt fetchedAt: saved.membership.lastSuccessfulAt ?
? new Date(saved.membership.lastSuccessfulAt) new Date(saved.membership.lastSuccessfulAt) :
: null, null,
freshForMs: 300_000, freshForMs: 300_000,
work: saved.membership.sync.id work: saved.membership.sync.id ?
? { ...saved.membership.sync, id: saved.membership.sync.id } { ...saved.membership.sync, id: saved.membership.sync.id } :
: null, null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
}; };
@@ -1,14 +1,16 @@
import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { import {
IncomeAnalyticsResourceDto,
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto, IncomeSummaryResourceDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income-analytics.service';
@ApiTags('brokerage-accounts') @ApiTags('brokerage-accounts')
@@ -1,13 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
import { MoneyDto } from '../../../common/money.dto';
import { import {
InvestmentDependencyDto, InvestmentDependencyDto,
InvestmentReadQueryDto, InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../investment-resource.dto'; } from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
@@ -102,16 +102,16 @@ export class IncomeInstrumentDto {
declare key: string; declare key: string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare figi: string | null; declare figi: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null; declare instrumentUid: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare ticker: string | null; declare ticker: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null; declare instrumentType: null | string;
@ApiProperty() @ApiProperty()
declare displayName: string; declare displayName: string;
@@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
declare yearComparison: IncomeComparisonDto | null; declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'week' | 'month'; declare bucketGranularity: 'day' | 'month' | 'week';
@ApiProperty({ type: IncomeBucketDto, isArray: true }) @ApiProperty({ type: IncomeBucketDto, isArray: true })
declare buckets: IncomeBucketDto[]; declare buckets: IncomeBucketDto[];
@@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
export class IncomeSummaryResourceDto { export class IncomeSummaryResourceDto {
@ApiProperty({ type: IncomeSummaryDto, nullable: true }) @ApiProperty({ type: IncomeSummaryDto, nullable: true })
declare data: IncomeSummaryDto | null; declare data: IncomeSummaryDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto {
} }
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto { export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
@ApiProperty({ @ApiProperty({
type: IncomeDependencyDto, type: IncomeDependencyDto,
isArray: true, isArray: true,
@@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
export class IncomeAnalyticsResourceDto { export class IncomeAnalyticsResourceDto {
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true }) @ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
declare data: IncomeAnalyticsDto | null; declare data: IncomeAnalyticsDto | null;
@ApiProperty({ type: IncomeAnalyticsMetaDto }) @ApiProperty({ type: IncomeAnalyticsMetaDto })
declare meta: IncomeAnalyticsMetaDto; declare meta: IncomeAnalyticsMetaDto;
} }
@@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
})); }));
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service'; import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => { describe('IncomeAnalyticsService', () => {
@@ -1,5 +1,6 @@
import assert from 'node:assert/strict';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import assert from 'node:assert/strict';
import { import {
add, add,
compare, compare,
@@ -7,24 +8,21 @@ import {
formatDecimal, formatDecimal,
subtract, subtract,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
IncomeAnalyticsResourceDto,
IncomeDependencyDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
BrokerageOperationDto, BrokerageOperationDto,
BrokerageOperationsService, BrokerageOperationsService,
} from '../operations/operations.api'; } from '../operations/operations.api';
import { import {
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto,
IncomeComparisonDto, IncomeComparisonDto,
IncomeCurrencySummaryDto, IncomeCurrencySummaryDto,
IncomeDependencyDto,
IncomeInstrumentDto, IncomeInstrumentDto,
IncomeSummaryDto, IncomeSummaryDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { import {
IncomeBucketGranularity, IncomeBucketGranularity,
@@ -96,9 +94,9 @@ export class IncomeAnalyticsService {
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' }, { ...prior.meta, role: 'previous' },
{ {
...(plan.yearAgo && year ...(plan.yearAgo && year ?
? year.meta year.meta :
: investmentResourceMeta({ investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable', dataRevision: 'unavailable',
availability: 'missing', availability: 'missing',
@@ -119,9 +117,9 @@ export class IncomeAnalyticsService {
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision, dataRevision: current.meta.dataRevision,
availability: current.meta.availability, availability: current.meta.availability,
fetchedAt: current.meta.lastSuccessfulAt fetchedAt: current.meta.lastSuccessfulAt ?
? new Date(current.meta.lastSuccessfulAt) new Date(current.meta.lastSuccessfulAt) :
: null, null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0, freshForMs: current.meta.freshness === 'fresh' ? Infinity : 0,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage, coverage: current.meta.coverage,
@@ -144,13 +142,13 @@ export class IncomeAnalyticsService {
current.meta.lastSuccessfulAt, current.meta.lastSuccessfulAt,
), ),
previous: previous:
prior.items === null prior.items === null ?
? null null :
: comparison(selected, plan.previous, qualifying(prior.items)), comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison: yearComparison:
plan.yearAgo && year?.items !== null && year plan.yearAgo && year?.items !== null && year ?
? comparison(selected, plan.yearAgo, qualifying(year.items)) comparison(selected, plan.yearAgo, qualifying(year.items)) :
: null, null,
bucketGranularity: plan.granularity, bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected), buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected), instruments: instruments(selected),
@@ -184,9 +182,9 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
if (!entry.payment) continue; if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal(); const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value); const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON') if (entry.type === 'OPERATION_TYPE_COUPON') {
total.coupon = add(total.coupon, value); total.coupon = add(total.coupon, value);
else total.dividend = add(total.dividend, value); } else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total); values.set(entry.payment.currency, total);
} }
return [...values] return [...values]
@@ -207,6 +205,7 @@ function comparison(
); );
const currencies = [ const currencies = [
...new Set([...selected.keys(), ...compared.keys()]), ...new Set([...selected.keys(), ...compared.keys()]),
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
].sort(); ].sort();
return { return {
period: comparedPeriod, period: comparedPeriod,
@@ -279,8 +278,9 @@ function instruments(
function instrumentKey(entry: BrokerageOperationDto): string { function instrumentKey(entry: BrokerageOperationDto): string {
if (entry.instrumentUid) return `uid:${entry.instrumentUid}`; if (entry.instrumentUid) return `uid:${entry.instrumentUid}`;
if (entry.figi) return `figi:${entry.figi}`; if (entry.figi) return `figi:${entry.figi}`;
if (entry.ticker || entry.instrumentType) if (entry.ticker || entry.instrumentType) {
return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`; return `ticker:${entry.ticker ?? ''}:${entry.instrumentType ?? ''}`;
}
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
} }
@@ -1,4 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { dateOnlyBounds } from '../investment-calendar'; import { dateOnlyBounds } from '../investment-calendar';
import { planIncomePeriod, rangeBuckets } from './income-period'; import { planIncomePeriod, rangeBuckets } from './income-period';
@@ -17,8 +18,9 @@ describe('income period planning', () => {
['2026-03-02', '2026-03-01'], ['2026-03-02', '2026-03-01'],
['2026-01-01', '2027-01-02'], ['2026-01-01', '2027-01-02'],
['2026-01-01', '2026-04-01'], ['2026-01-01', '2026-04-01'],
]) ]) {
expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException); expect(() => planIncomePeriod(from, to)).toThrow(BadRequestException);
}
}); });
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => { it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
+32 -29
View File
@@ -1,5 +1,6 @@
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { MILLISECONDS_PER_DAY } from '../../../common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -13,41 +14,41 @@ const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'week' | 'month'; export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
openedDate?: Date, openedDate?: Date,
): { ): {
selected: DateOnlyRange;
previous: DateOnlyRange;
yearAgo: DateOnlyRange | null;
granularity: IncomeBucketGranularity; granularity: IncomeBucketGranularity;
previous: DateOnlyRange;
selected: DateOnlyRange;
yearAgo: DateOnlyRange | null;
} { } {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date()); const today = moscowDate(new Date());
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected) const previous = isFullMonth(selected) ?
? previousMonth(selected.from) previousMonth(selected.from) :
: shiftDays(selected, -rangeDays(selected)); shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected); const candidate = yearAgo(selected);
const yearAgoRange = const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
? candidate candidate :
: null; null;
return { return {
selected, selected,
previous, previous,
yearAgo: yearAgoRange, yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
? 'day' 'day' :
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
? 'week' 'week' :
: 'month', 'month',
}; };
} }
@@ -55,24 +56,25 @@ export function rangeBuckets(
range: DateOnlyRange, range: DateOnlyRange,
granularity: IncomeBucketGranularity, granularity: IncomeBucketGranularity,
): DateOnlyRange[] { ): DateOnlyRange[] {
if (granularity === 'day') if (granularity === 'day') {
return dates(range).map((date) => ({ from: date, to: date })); return dates(range).map((date) => ({ from: date, to: date }));
}
const buckets: DateOnlyRange[] = []; const buckets: DateOnlyRange[] = [];
let cursor = let cursor =
granularity === 'week' granularity === 'week' ?
? addDays( addDays(
range.from, range.from,
-( -(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) % (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - 1) %
DAYS_PER_WEEK DAYS_PER_WEEK
), ),
) ) :
: `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`; `${range.from.slice(0, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' granularity === 'week' ?
? addDays(cursor, DAYS_PER_WEEK - 1) addDays(cursor, DAYS_PER_WEEK - 1) :
: endOfMonth(cursor); endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
@@ -86,7 +88,7 @@ export function rangeDays(range: DateOnlyRange): number {
return ( return (
Math.round( Math.round(
(utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) / (utcDate(range.to).valueOf() - utcDate(range.from).valueOf()) /
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
) + 1 ) + 1
); );
} }
@@ -109,8 +111,9 @@ function addDays(value: string, days: number): string {
function dates(range: DateOnlyRange): string[] { function dates(range: DateOnlyRange): string[] {
const result: string[] = []; const result: string[] = [];
for (let date = range.from; date <= range.to; date = addDays(date, 1)) for (let date = range.from; date <= range.to; date = addDays(date, 1)) {
result.push(date); result.push(date);
}
return result; return result;
} }
@@ -142,9 +145,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); const [year, month, day] = dateParts(value);
const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`; const result = `${String(year - 1).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
return utcDate(result).toISOString().slice(0, 10) === result return utcDate(result).toISOString().slice(0, 10) === result ?
? result result :
: null; null;
}; };
const from = shift(range.from); const from = shift(range.from);
const to = shift(range.to); const to = shift(range.to);
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { OperationsModule } from '../operations/operations.module'; import { OperationsModule } from '../operations/operations.module';
import { IncomeAnalyticsController } from './income-analytics.controller'; import { IncomeAnalyticsController } from './income-analytics.controller';
@@ -1,13 +1,13 @@
import { planInvestmentScreen } from '../../investment-sync-plan';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException, ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { formatDecimal } from '../../../../common/decimal'; import { formatDecimal } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '../../../../integrations/tbank/decimal';
import { import {
TbankAccessService, TbankAccessService,
@@ -16,35 +16,36 @@ import {
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { planInvestmentScreen } from '../../investment-sync-plan';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
candleGaps, candleGaps,
type CandleRange,
candleWindows, candleWindows,
utcDay, utcDay,
type CandleRange,
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market'; import { timestamp } from './instrument-market';
type CandleInput = { type CandleInput = {
uid: string; instrumentType: 'bond' | 'share';
instrumentType: 'share' | 'bond'; period: 'all' | 'halfYear' | 'month' | 'year';
period: 'month' | 'halfYear' | 'year' | 'all';
range?: CandleRange; range?: CandleRange;
uid: string;
}; };
type Producer = { type Producer = {
input: CandleInput;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
now: Date;
force: boolean; force: boolean;
priority: number; input: CandleInput;
now: Date;
observers: TbankSyncAttempt[]; observers: TbankSyncAttempt[];
pass?: { pass?: {
now: Date;
force: boolean; force: boolean;
ranges: CandleRange[];
nextWindow: number; nextWindow: number;
now: Date;
ranges: CandleRange[];
}; };
priority: number;
}; };
const months = { month: 1, halfYear: 6, year: 12 }; const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000; const tailFreshForMs = 60_000;
@@ -61,18 +62,20 @@ function rangeFor(
const from = utcDay(now); const from = utcDay(now);
const history = timestamp(instrument.first_1dayCandleDate); const history = timestamp(instrument.first_1dayCandleDate);
if (input.period === 'all') { if (input.period === 'all') {
if (!history) if (!history) {
throw new ServiceUnavailableException({ throw new ServiceUnavailableException({
code: code:
input.instrumentType === 'share' input.instrumentType === 'share' ?
? 'STOCK_HISTORY_START_UNAVAILABLE' 'STOCK_HISTORY_START_UNAVAILABLE' :
: 'BOND_HISTORY_START_UNAVAILABLE', 'BOND_HISTORY_START_UNAVAILABLE',
}); });
}
from.setTime(utcDay(new Date(history)).valueOf()); from.setTime(utcDay(new Date(history)).valueOf());
} else { } else {
from.setUTCMonth(from.getUTCMonth() - months[input.period]); from.setUTCMonth(from.getUTCMonth() - months[input.period]);
if (history && Date.parse(history) > from.valueOf()) if (history && Date.parse(history) > from.valueOf()) {
from.setTime(utcDay(new Date(history)).valueOf()); from.setTime(utcDay(new Date(history)).valueOf());
}
} }
return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) }; return { from, to: new Date(utcDay(now).valueOf() + MILLISECONDS_PER_DAY) };
} }
@@ -102,16 +105,18 @@ export class CandleService {
true, true,
['detail'], ['detail'],
); );
if (detail.meta.availability === 'complete' && !detail.instrument) if (detail.meta.availability === 'complete' && !detail.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const now = new Date(); const now = new Date();
const range = detail.instrument const range = detail.instrument ?
? rangeFor(input, detail.instrument, now) rangeFor(input, detail.instrument, now) :
: null; null;
const saved = range ? await this.repository.read(input.uid, range) : null; const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
if (!observe && (!saved || gaps.length)) if (!observe && (!saved || gaps.length)) {
await this.request(userId, revision, input, now); await this.request(userId, revision, input, now);
}
const work = await this.repository.work(userId, revision, scope(input)); const work = await this.repository.work(userId, revision, scope(input));
const fetchedAt = const fetchedAt =
saved?.coverage saved?.coverage
@@ -129,14 +134,14 @@ export class CandleService {
}); });
return { return {
data: data:
!saved || !covered !saved || !covered ?
? null null :
: { {
candles: saved.data, candles: saved.data,
currency: currency:
input.instrumentType === 'share' input.instrumentType === 'share' ?
? detail.instrument?.currency?.toUpperCase() || null detail.instrument?.currency?.toUpperCase() || null :
: null, null,
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
observedAt: fetchedAt?.toISOString() ?? now.toISOString(), observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
}, },
@@ -149,8 +154,8 @@ export class CandleService {
work, work,
coverage: { coverage: {
requested: range ? [publicRange(range)] : [], requested: range ? [publicRange(range)] : [],
completeRanges: range completeRanges: range ?
? completeCoverage.flatMap((row) => { completeCoverage.flatMap((row) => {
const from = new Date( const from = new Date(
Math.max(row.from.valueOf(), range.from.valueOf()), Math.max(row.from.valueOf(), range.from.valueOf()),
); );
@@ -158,22 +163,22 @@ export class CandleService {
Math.min(row.to.valueOf(), range.to.valueOf()), Math.min(row.to.valueOf(), range.to.valueOf()),
); );
return from < to ? [publicRange({ from, to })] : []; return from < to ? [publicRange({ from, to })] : [];
}) }) :
: [], [],
missingRanges: missing.map(publicRange), missingRanges: missing.map(publicRange),
asOf: fetchedAt?.toISOString() ?? null, asOf: fetchedAt?.toISOString() ?? null,
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
? 'known' 'known' :
: 'unknown', 'unknown',
historyState: complete historyState: complete ?
? 'complete' 'complete' :
: work?.state === 'failed' work?.state === 'failed' ?
? 'failed' 'failed' :
: work?.state === 'running' work?.state === 'running' ?
? 'loading' 'loading' :
: covered covered ?
? 'partial' 'partial' :
: 'missing', 'missing',
}, },
dependencies: [ dependencies: [
detail.meta, detail.meta,
@@ -183,9 +188,9 @@ export class CandleService {
dataRevision: String(row.dataRevision), dataRevision: String(row.dataRevision),
availability: 'complete', availability: 'complete',
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
freshForMs: row.isComplete freshForMs: row.isComplete ?
? Number.POSITIVE_INFINITY Number.POSITIVE_INFINITY :
: tailFreshForMs, tailFreshForMs,
work: null, work: null,
}), }),
), ),
@@ -197,13 +202,14 @@ export class CandleService {
prefetch( prefetch(
userId: string, userId: string,
revision: string, revision: string,
positions: { instrumentUid?: string | null; type: string }[], positions: { instrumentUid?: null | string; type: string }[],
) { ) {
const key = JSON.stringify([ const key = JSON.stringify([
userId, userId,
revision, revision,
positions positions
.map((position) => [position.instrumentUid, position.type]) .map((position) => [position.instrumentUid, position.type])
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
.sort(), .sort(),
]); ]);
const existing = this.prefetches.get(key); const existing = this.prefetches.get(key);
@@ -218,14 +224,14 @@ export class CandleService {
private async prefetchPositions( private async prefetchPositions(
userId: string, userId: string,
revision: string, revision: string,
positions: { instrumentUid?: string | null; type: string }[], positions: { instrumentUid?: null | string; type: string }[],
) { ) {
const now = new Date(); const now = new Date();
const unique = new Map( const unique = new Map(
positions positions
.filter( .filter(
(position) => (position) =>
position.instrumentUid && ['STOCK', 'BOND'].includes(position.type), position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
) )
.map((position) => [position.instrumentUid ?? '', position]), .map((position) => [position.instrumentUid ?? '', position]),
); );
@@ -267,7 +273,7 @@ export class CandleService {
instruments: [ instruments: [
{ {
uid, uid,
type: position.type as 'STOCK' | 'BOND', type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null, historyFrom: history ? new Date(history) : null,
}, },
], ],
@@ -280,9 +286,9 @@ export class CandleService {
) { ) {
const input = { uid, instrumentType, period: 'all' as const }; const input = { uid, instrumentType, period: 'all' as const };
const range = const range =
detail?.instrument && history detail?.instrument && history ?
? rangeFor(input, detail.instrument, now) rangeFor(input, detail.instrument, now) :
: null; null;
if ( if (
!range || !range ||
candleGaps( candleGaps(
@@ -290,8 +296,9 @@ export class CandleService {
coverage.filter((row) => row.instrumentUid === uid), coverage.filter((row) => row.instrumentUid === uid),
now, now,
).length ).length
) ) {
await this.request(userId, revision, input, now, false, 2); await this.request(userId, revision, input, now, false, 2);
}
} }
} }
} }
@@ -379,6 +386,7 @@ export class CandleService {
} }
const { ranges, now, force } = pass; const { ranges, now, force } = pass;
const index = pass.nextWindow; const index = pass.nextWindow;
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
let window = ranges[index]; let window = ranges[index];
if (window && !force) { if (window && !force) {
const latest = await this.repository.read(input.uid, window); const latest = await this.repository.read(input.uid, window);
@@ -386,6 +394,7 @@ export class CandleService {
.flatMap(candleWindows) .flatMap(candleWindows)
.reverse(); .reverse();
ranges.splice(index, 1, ...gaps); ranges.splice(index, 1, ...gaps);
// eslint-disable-next-line security/detect-object-injection -- The index is the internal numeric window cursor.
window = ranges[index]; window = ranges[index];
} }
if (window) { if (window) {
@@ -408,8 +417,9 @@ export class CandleService {
}, },
]; ];
}); });
if (!(await this.repository.stage(attempt, window, rows, now))) if (!(await this.repository.stage(attempt, window, rows, now))) {
return false; return false;
}
if ( if (
!force && !force &&
!(await this.repository.publish( !(await this.repository.publish(
@@ -417,8 +427,9 @@ export class CandleService {
{ uid: input.uid, ranges: [window] }, { uid: input.uid, ranges: [window] },
false, false,
)) ))
) ) {
return false; return false;
}
pass.nextWindow = index + 1; pass.nextWindow = index + 1;
if (pass.nextWindow < ranges.length) return null; if (pass.nextWindow < ranges.length) return null;
} }
@@ -441,16 +452,18 @@ export class CandleService {
} }
success = result; success = result;
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
}
if (error instanceof ServiceUnavailableException) { if (error instanceof ServiceUnavailableException) {
const response = error.getResponse(); const response = error.getResponse();
if ( if (
typeof response === 'object' && typeof response === 'object' &&
'code' in response && 'code' in response &&
typeof response.code === 'string' typeof response.code === 'string'
) ) {
code = response.code; code = response.code;
}
} }
} }
// Seal publication with reservations so a late forced join cannot be // Seal publication with reservations so a late forced join cannot be
@@ -458,8 +471,9 @@ export class CandleService {
const finished = this.reservations.then(async () => { const finished = this.reservations.then(async () => {
const pass = producer.pass; const pass = producer.pass;
if (success && pass) { if (success && pass) {
if (producer.force && (!pass.force || producer.now > pass.now)) if (producer.force && (!pass.force || producer.now > pass.now)) {
return false; return false;
}
try { try {
success = await this.repository.publish(producer.attempt, { success = await this.repository.publish(producer.attempt, {
uid: producer.input.uid, uid: producer.input.uid,
@@ -478,8 +492,9 @@ export class CandleService {
observer, observer,
producer.attempt, producer.attempt,
)) ))
) ) {
await this.repository.fail(observer, code); await this.repository.fail(observer, code);
}
this.active.delete(observer.attemptId); this.active.delete(observer.attemptId);
} }
this.producers.delete(scope(producer.input)); this.producers.delete(scope(producer.input));
@@ -490,7 +505,7 @@ export class CandleService {
this.queue.push(producer); this.queue.push(producer);
continue; continue;
} }
if (success && !producer.force && producer.input.period !== 'all') if (success && !producer.force && producer.input.period !== 'all') {
await this.request( await this.request(
producer.attempt.userId, producer.attempt.userId,
producer.attempt.revision, producer.attempt.revision,
@@ -499,6 +514,7 @@ export class CandleService {
false, false,
2, 2,
); );
}
} }
} catch { } catch {
this.logger.event('error', 'candles.persistence-failed', { this.logger.event('error', 'candles.persistence-failed', {
@@ -1,10 +1,10 @@
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ConflictException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
@@ -17,9 +17,10 @@ import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
catalogScope,
type CatalogQuery, type CatalogQuery,
catalogScope,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
catalogSearchLimit, catalogSearchLimit,
selectCatalogInstruments, selectCatalogInstruments,
@@ -28,10 +29,10 @@ import {
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
type Producer = { type Producer = {
input: CatalogQuery;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
observers: TbankSyncAttempt[];
force: boolean; force: boolean;
input: CatalogQuery;
observers: TbankSyncAttempt[];
}; };
@Injectable() @Injectable()
export class CatalogDiscoveryService { export class CatalogDiscoveryService {
@@ -43,8 +44,9 @@ export class CatalogDiscoveryService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ workId: string; done: Promise<void>; resolve: () => void } { done: Promise<void>; resolve: () => void; workId: string }
>(); >();
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository, private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
@@ -73,14 +75,14 @@ export class CatalogDiscoveryService {
async search(userId: string, input: CatalogQuery, observe = false) { async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe); const discovery = await this.read(userId, input, observe);
const sources = discovery.data const sources = discovery.data ?
? await this.enrichment.readMany( await this.enrichment.readMany(
userId, userId,
input.instrumentType, input.instrumentType,
discovery.data.map((item) => item.uid), discovery.data.map((item) => item.uid),
observe, observe,
) ) :
: []; [];
const dependencies = [ const dependencies = [
discovery.meta, discovery.meta,
...sources.map((source) => source.meta), ...sources.map((source) => source.meta),
@@ -108,22 +110,22 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: availability:
discovery.data === null discovery.data === null ?
? 'missing' 'missing' :
: sources.every((source) => source.meta.availability === 'complete') sources.every((source) => source.meta.availability === 'complete') ?
? 'complete' 'complete' :
: 'partial', 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt fetchedAt: discovery.meta.lastSuccessfulAt ?
? new Date(discovery.meta.lastSuccessfulAt) new Date(discovery.meta.lastSuccessfulAt) :
: null, null,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id work: discovery.meta.sync.id ?
? { {
id: discovery.meta.sync.id, id: discovery.meta.sync.id,
state: discovery.meta.sync.state, state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode, errorCode: discovery.meta.sync.errorCode,
} } :
: null, null,
dependencies, dependencies,
}), }),
}; };
@@ -131,7 +133,7 @@ export class CatalogDiscoveryService {
async detail( async detail(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
observe = false, observe = false,
) { ) {
@@ -143,22 +145,24 @@ export class CatalogDiscoveryService {
['detail'], ['detail'],
); );
const source = sources[0]; const source = sources[0];
if (source.meta.availability === 'complete' && !source.instrument) if (source.meta.availability === 'complete' && !source.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const instrument = source.instrument; const instrument = source.instrument;
let slug = uid; let slug = uid;
const dependencies = [source.meta]; const dependencies = [source.meta];
if ( if (
instrument?.ticker && instrument?.ticker &&
/^[A-Za-z0-9][A-Za-z0-9._@+-]{0,79}$/.test(instrument.ticker) /^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) { ) {
const alias = await this.read( const alias = await this.read(
userId, userId,
{ instrumentType, query: instrument.ticker, exactTicker: true }, { instrumentType, query: instrument.ticker, exactTicker: true },
observe, observe,
); );
if (alias.data?.length === 1 && alias.data[0].uid === uid) if (alias.data?.length === 1 && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase(); slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta); dependencies.push(alias.meta);
} }
return { return {
@@ -169,17 +173,17 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: source.meta.availability, availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt fetchedAt: source.meta.lastSuccessfulAt ?
? new Date(source.meta.lastSuccessfulAt) new Date(source.meta.lastSuccessfulAt) :
: null, null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id work: source.meta.sync.id ?
? { {
id: source.meta.sync.id, id: source.meta.sync.id,
state: source.meta.sync.state, state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode, errorCode: source.meta.sync.errorCode,
} } :
: null, null,
dependencies, dependencies,
}), }),
}; };
@@ -187,7 +191,7 @@ export class CatalogDiscoveryService {
async byTicker( async byTicker(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false, observe = false,
) { ) {
@@ -197,38 +201,39 @@ export class CatalogDiscoveryService {
observe, observe,
); );
if (lookup.data?.length === 0) throw new NotFoundException(); if (lookup.data?.length === 0) throw new NotFoundException();
if (lookup.data && lookup.data.length > 1) if (lookup.data && lookup.data.length > 1) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' instrumentType === 'share' ?
? 'STOCK_TICKER_AMBIGUOUS' 'STOCK_TICKER_AMBIGUOUS' :
: 'BOND_TICKER_AMBIGUOUS', 'BOND_TICKER_AMBIGUOUS',
}); });
const result = lookup.data }
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) const result = lookup.data ?
: null; await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return { return {
data: result?.data data: result?.data ?
? { ...result.data, slug: ticker.trim().toUpperCase() } { ...result.data, slug: ticker.trim().toUpperCase() } :
: null, null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: result?.meta.availability ?? 'missing', availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt fetchedAt: lookup.meta.lastSuccessfulAt ?
? new Date(lookup.meta.lastSuccessfulAt) new Date(lookup.meta.lastSuccessfulAt) :
: null, null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id work: lookup.meta.sync.id ?
? { {
id: lookup.meta.sync.id, id: lookup.meta.sync.id,
state: lookup.meta.sync.state, state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode, errorCode: lookup.meta.sync.errorCode,
} } :
: null, null,
dependencies, dependencies,
}), }),
}; };
@@ -245,8 +250,9 @@ export class CatalogDiscoveryService {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
if ( if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) !tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
) ) {
return null; return null;
}
const reservation = await this.repository.reserve( const reservation = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -291,12 +297,13 @@ export class CatalogDiscoveryService {
async refresh(userId: string, revision: string, query: CatalogQuery) { async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true); const id = await this.request(userId, revision, query, true);
if (id) if (id) {
await Promise.all( await Promise.all(
[...this.completions.values()] [...this.completions.values()]
.filter((entry) => entry.workId === id) .filter((entry) => entry.workId === id)
.map((entry) => entry.done), .map((entry) => entry.done),
); );
}
const saved = await this.repository.read(catalogQuery(query)); const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany( const sources = await this.enrichment.readMany(
userId, userId,
@@ -323,19 +330,19 @@ export class CatalogDiscoveryService {
const token = await this.access.tokenForAttempt(attempt); const token = await this.access.tokenForAttempt(attempt);
if (token) { if (token) {
const found = const found =
input.instrumentType === 'share' input.instrumentType === 'share' ?
? await this.tbank.findShares(token, input.query) await this.tbank.findShares(token, input.query) :
: await this.tbank.findBonds(token, input.query); await this.tbank.findBonds(token, input.query);
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
input, input,
selectCatalogInstruments( selectCatalogInstruments(
input.exactTicker input.exactTicker ?
? found.filter( found.filter(
(item) => (item) =>
item.ticker?.trim().toUpperCase() === input.query, item.ticker?.trim().toUpperCase() === input.query,
) ) :
: found, found,
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice( ).slice(
@@ -346,17 +353,18 @@ export class CatalogDiscoveryService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
else } else {
this.logger.event('warn', 'catalog-discovery.failed', { this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE', code: 'TBANK_UNAVAILABLE',
}); });
}
} }
if (success) { if (success) {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === 1) if (!input.exactTicker || saved.data?.length === 1) {
await this.enrichment.requestMany( await this.enrichment.requestMany(
observer.userId, observer.userId,
observer.revision, observer.revision,
@@ -365,6 +373,8 @@ export class CatalogDiscoveryService {
producer.force, producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'], input.exactTicker ? ['detail'] : ['last-price', 'detail'],
); );
}
}
} }
this.producers.delete(catalogScope(input)); this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) { for (const observer of [attempt, ...producer.observers]) {
@@ -1,47 +1,47 @@
import type { MoexBondEvent } from '../../../../integrations/moex/moex.api'; import { Injectable, NotFoundException } from '@nestjs/common';
import { CandleRepository } from '../infrastructure/candle.repository'; import { randomUUID } from 'node:crypto';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { import {
decimalFromString, decimalFromString,
divide, divide,
formatDecimal, formatDecimal,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND, MILLISECONDS_PER_SECOND,
} from '../../../../common/time'; } from '../../../../common/time';
import { MoexClient } from '../../../../integrations/moex/moex.api'; import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { previousClose, timestamp } from './instrument-market'; import { decimal } from '../../../../integrations/tbank/decimal';
import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
TbankClient,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
TbankClient,
type TbankCoupon, type TbankCoupon,
type TbankDividend, type TbankDividend,
type TbankFundamentals, type TbankFundamentals,
type TbankLastPrice, type TbankLastPrice,
type TbankShare, type TbankShare,
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogScope, catalogScope,
type CatalogSource, type CatalogSource,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market';
type Producer = { type Producer = {
input: CatalogSource;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
input: CatalogSource;
observers: { attempt: TbankSyncAttempt; input: CatalogSource }[]; observers: { attempt: TbankSyncAttempt; input: CatalogSource }[];
}; };
@Injectable() @Injectable()
@@ -54,13 +54,13 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ workId: string; done: Promise<void>; resolve: () => void } { done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
userId: string, userId: string,
revision: string, revision: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
) { ) {
const ids = await this.requestMany( const ids = await this.requestMany(
@@ -82,6 +82,7 @@ export class CatalogEnrichmentService {
if (!rows.length) throw new Error('Instrument dependency missing'); if (!rows.length) throw new Error('Instrument dependency missing');
return (rows[0].payload as CatalogPayload).instrument ?? null; return (rows[0].payload as CatalogPayload).instrument ?? null;
} }
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository, private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
@@ -94,21 +95,21 @@ export class CatalogEnrichmentService {
async readMany( async readMany(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
observe: boolean, observe: boolean,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'], kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
) { ) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const rows = inputs.length ? await this.repository.readSources(inputs) : []; const rows = inputs.length ? await this.repository.readSources(inputs) : [];
if (!observe) if (!observe) {
await this.requestMany( await this.requestMany(
userId, userId,
revision, revision,
@@ -118,9 +119,10 @@ export class CatalogEnrichmentService {
kinds, kinds,
range, range,
); );
const work = inputs.length }
? await this.repository.workMany(userId, revision, inputs) const work = inputs.length ?
: []; await this.repository.workMany(userId, revision, inputs) :
[];
const byScope = new Map(work.map((row) => [row.scopeKey, row])); const byScope = new Map(work.map((row) => [row.scopeKey, row]));
return inputs.map((input) => { return inputs.map((input) => {
const row = rows.find( const row = rows.find(
@@ -130,16 +132,16 @@ export class CatalogEnrichmentService {
const payload = row?.payload as CatalogPayload | undefined; const payload = row?.payload as CatalogPayload | undefined;
return { return {
input, input,
version: row version: row ?
? { {
instrumentUid: row.instrumentUid, instrumentUid: row.instrumentUid,
source: row.source, source: row.source,
kind: row.kind, kind: row.kind,
rangeKey: row.rangeKey, rangeKey: row.rangeKey,
dataRevision: row.dataRevision, dataRevision: row.dataRevision,
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
} } :
: null, null,
payload, payload,
instrument: payload?.instrument ?? null, instrument: payload?.instrument ?? null,
price: payload?.price ?? null, price: payload?.price ?? null,
@@ -158,22 +160,22 @@ export class CatalogEnrichmentService {
requestMany( requestMany(
userId: string, userId: string,
revision: string, revision: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
force = false, force = false,
kinds: CatalogSource['kind'][] = ['last-price', 'detail'], kinds: CatalogSource['kind'][] = ['last-price', 'detail'],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => { const reservation = this.reservations.then(async () => {
const rows = inputs.length const rows = inputs.length ?
? await this.repository.readSources(inputs) await this.repository.readSources(inputs) :
: []; [];
const ids: string[] = []; const ids: string[] = [];
for (const input of inputs) { for (const input of inputs) {
const row = rows.find( const row = rows.find(
@@ -187,8 +189,9 @@ export class CatalogEnrichmentService {
new Date(), new Date(),
force, force,
) )
) ) {
continue; continue;
}
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -229,24 +232,24 @@ export class CatalogEnrichmentService {
async section( async section(
userId: string, userId: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uid: string, uid: string,
section: 'quote' | 'company' | 'metrics', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
section === 'company' section === 'company' ?
? ['detail', 'description', 'fundamentals'] ['detail', 'description', 'fundamentals'] :
: [ [
...(instrumentType === 'bond' ...(instrumentType === 'bond' ?
? ['bond-current' as const] ['bond-current' as const] :
: ['detail' as const]), ['detail' as const]),
'last-price', 'last-price',
'trading-status', 'trading-status',
'comparison', 'comparison',
...(section === 'metrics' ...(section === 'metrics' ?
? ['coupons' as const, 'yield' as const, 'rating' as const] ['coupons' as const, 'yield' as const, 'rating' as const] :
: []), []),
]; ];
const sources = await this.readMany( const sources = await this.readMany(
userId, userId,
@@ -256,8 +259,9 @@ export class CatalogEnrichmentService {
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument) if (first.meta.availability === 'complete' && !first.instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const values = sources.reduce<CatalogPayload>( const values = sources.reduce<CatalogPayload>(
(result, source) => ({ ...result, ...source.payload }), (result, source) => ({ ...result, ...source.payload }),
{}, {},
@@ -271,18 +275,18 @@ export class CatalogEnrichmentService {
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: !first.instrument availability: !first.instrument ?
? 'missing' 'missing' :
: complete complete ?
? 'complete' 'complete' :
: 'partial', 'partial',
fetchedAt: first.meta.lastSuccessfulAt fetchedAt: first.meta.lastSuccessfulAt ?
? new Date(first.meta.lastSuccessfulAt) new Date(first.meta.lastSuccessfulAt) :
: null, null,
freshForMs: freshForMs:
section === 'company' section === 'company' ?
? tbankSourceTtlMs.company tbankSourceTtlMs.company :
: tbankSourceTtlMs.quote, tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -307,18 +311,21 @@ export class CatalogEnrichmentService {
): Promise<CatalogPayload> { ): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') { if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument = const instrument =
input.instrumentType === 'share' input.instrumentType === 'share' ?
? await this.tbank.getShare(token, input.uid) await this.tbank.getShare(token, input.uid) :
: await this.tbank.getBond(token, input.uid); await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch'); throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response'); if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null }; return { instrument: instrument ? publicInstrument(instrument) : null };
} }
if (input.kind === 'trading-status') if (input.kind === 'trading-status') {
return { return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid), tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
}; };
}
if (input.kind === 'comparison') { if (input.kind === 'comparison') {
const prices = await this.repository.readSources([ const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined }, { ...input, kind: 'last-price', rangeKey: undefined },
@@ -343,16 +350,18 @@ export class CatalogEnrichmentService {
); );
const rows = response.map((candle) => { const rows = response.map((candle) => {
const time = timestamp(candle.time); const time = timestamp(candle.time);
if (!time || !candle.close) if (!time || !candle.close) {
throw new Error('Invalid candle response'); throw new Error('Invalid candle response');
}
return { return {
time, time,
close: formatDecimal(decimal(candle.close)), close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true, isComplete: candle.isComplete === true,
}; };
}); });
if (!(await this.candles.stage(attempt, gap, rows, now))) if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison'); throw new Error('Cancelled candle comparison');
}
} }
if ( if (
gaps.length && gaps.length &&
@@ -361,8 +370,9 @@ export class CatalogEnrichmentService {
{ uid: input.uid, ranges: gaps }, { uid: input.uid, ranges: gaps },
false, false,
)) ))
) ) {
throw new Error('Cancelled candle comparison'); throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map( const values = (await this.candles.read(input.uid, range)).data.map(
(row) => { (row) => {
const value = divide( const value = divide(
@@ -386,48 +396,51 @@ export class CatalogEnrichmentService {
} }
const details = await this.repository.readSources([ const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined }, { ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond' ...(input.instrumentType === 'bond' ?
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
: []), []),
]); ]);
const instrument = details const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument) .map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean); .find(Boolean);
if ( if (
!instrument && !instrument &&
!['dividends', 'coupons', 'bond-events'].includes(input.kind) !['bond-events', 'coupons', 'dividends'].includes(input.kind)
) ) {
throw new Error('Instrument dependency missing'); throw new Error('Instrument dependency missing');
if (input.kind === 'description') }
if (input.kind === 'description') {
return { return {
description: instrument?.assetUid description: instrument?.assetUid ?
? await this.tbank.getAssetDescription(token, instrument?.assetUid) await this.tbank.getAssetDescription(token, instrument?.assetUid) :
: null, null,
}; };
if (input.kind === 'fundamentals') }
if (input.kind === 'fundamentals') {
return { return {
fundamentals: instrument?.assetUid fundamentals: instrument?.assetUid ?
? await this.tbank.getFundamentals(token, instrument?.assetUid) await this.tbank.getFundamentals(token, instrument?.assetUid) :
: null, null,
}; };
}
if (input.kind === 'coupons' || input.kind === 'dividends') { if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey); const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[') const [from, to] = input.rangeKey?.startsWith('[') ?
? (JSON.parse(input.rangeKey) as [string, string]) (JSON.parse(input.rangeKey) as [string, string]) :
: [ [
`${year - payoutHistoryYears}-01-01`, `${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`, `${year + payoutFutureYears}-01-01`,
]; ];
return input.kind === 'coupons' return input.kind === 'coupons' ?
? { {
coupons: await this.tbank.getBondCoupons( coupons: await this.tbank.getBondCoupons(
token, token,
input.uid, input.uid,
from, from,
to, to,
), ),
} } :
: { {
dividends: await this.tbank.getDividends( dividends: await this.tbank.getDividends(
token, token,
input.uid, input.uid,
@@ -441,23 +454,25 @@ export class CatalogEnrichmentService {
if (!ticker) throw new Error('Bond ticker missing'); if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) }; return { bondEvents: await this.moex.bondEvents(ticker) };
} }
if (input.kind === 'yield') if (input.kind === 'yield') {
return { return {
effectiveYield: effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode instrument?.ticker && instrument.isin && instrument.classCode ?
? await this.moex.bondYield( await this.moex.bondYield(
instrument.ticker, instrument.ticker,
instrument.isin, instrument.isin,
instrument.classCode, instrument.classCode,
) ) :
: null, null,
}; };
if (input.kind === 'rating') }
if (input.kind === 'rating') {
return { return {
creditRating: instrument?.isin creditRating: instrument?.isin ?
? await this.ratings.rating(instrument.isin) await this.ratings.rating(instrument.isin) :
: null, null,
}; };
}
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
} }
@@ -473,6 +488,7 @@ export class CatalogEnrichmentService {
let i = 0; let i = 0;
i < this.queue.length && batch.length < catalogSearchLimit; i < this.queue.length && batch.length < catalogSearchLimit;
) { ) {
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
const next = this.queue[i]; const next = this.queue[i];
if ( if (
next.input.kind === 'last-price' && next.input.kind === 'last-price' &&
@@ -490,38 +506,40 @@ export class CatalogEnrichmentService {
try { try {
const token = await this.access.tokenForAttempt(producer.attempt); const token = await this.access.tokenForAttempt(producer.attempt);
if (!token) failed = true; if (!token) failed = true;
else if (producer.input.kind === 'last-price') else if (producer.input.kind === 'last-price') {
prices = await this.tbank.getLastPrices( prices = await this.tbank.getLastPrices(
token, token,
batch.map((item) => item.input.uid), batch.map((item) => item.input.uid),
); );
else } else {
payload = await this.load(producer.input, token, producer.attempt); payload = await this.load(producer.input, token, producer.attempt);
}
} catch (error) { } catch (error) {
failed = true; failed = true;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
else } else {
this.logger.event('warn', 'catalog-enrichment.failed', { this.logger.event('warn', 'catalog-enrichment.failed', {
code: 'TBANK_UNAVAILABLE', code: 'TBANK_UNAVAILABLE',
}); });
}
} }
for (const item of batch) { for (const item of batch) {
const price = const price =
prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
null; null;
const value = const value =
item.input.kind === 'last-price' item.input.kind === 'last-price' ?
? { {
price: price price: price ?
? { {
instrumentUid: item.input.uid, instrumentUid: item.input.uid,
price: price.price, price: price.price,
time: price.time, time: price.time,
} } :
: null, null,
} } :
: payload; payload;
// Keep accepting observers while publication awaits SQLite. // Keep accepting observers while publication awaits SQLite.
item.observers.unshift({ attempt: item.attempt, input: item.input }); item.observers.unshift({ attempt: item.attempt, input: item.input });
for (const observer of item.observers) { for (const observer of item.observers) {
@@ -558,14 +576,14 @@ export class CatalogEnrichmentService {
} }
} }
function sourceInputs( function sourceInputs(
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
uids: string[], uids: string[],
kinds: CatalogSource['kind'][], kinds: CatalogSource['kind'][],
range?: { range?: {
from: string; from: string;
to: string;
ticker?: string; ticker?: string;
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string;
}, },
): CatalogSource[] { ): CatalogSource[] {
return kinds.flatMap((kind) => return kinds.flatMap((kind) =>
@@ -573,20 +591,22 @@ function sourceInputs(
instrumentType, instrumentType,
uid, uid,
kind, kind,
...(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 } ...(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.
...(['coupons', 'dividends'].includes(kind) { ticker: range?.tickers?.[uid] || range?.ticker } :
? { {}),
rangeKey: range ...(['coupons', 'dividends'].includes(kind) ?
? JSON.stringify([range.from, range.to]) {
: String(new Date().getUTCFullYear()), rangeKey: range ?
} JSON.stringify([range.from, range.to]) :
: {}), String(new Date().getUTCFullYear()),
} :
{}),
})), })),
); );
} }
function publicInstrument(value: TbankShare | TbankBond) { function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond; const bond = value as TbankBond;
return { return {
uid: value.uid, uid: value.uid,
@@ -625,38 +645,39 @@ const previousCloseLookbackMs =
const payoutHistoryYears = 5; const payoutHistoryYears = 5;
const payoutFutureYears = 2; const payoutFutureYears = 2;
export type CatalogPayload = { export type CatalogPayload = {
instrument?: TbankShare | TbankBond | null;
price?: TbankLastPrice | null;
candle?: TbankCandle | null;
tradingStatus?: string | null;
description?: string | null;
fundamentals?: TbankFundamentals | null;
coupons?: TbankCoupon[];
dividends?: TbankDividend[];
bondEvents?: MoexBondEvent[]; bondEvents?: MoexBondEvent[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>; candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>; creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
}; };
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
'last-price',
'bond-current', 'bond-current',
'trading-status',
'comparison', 'comparison',
'last-price',
'trading-status',
'yield', 'yield',
].includes(kind) ].includes(kind)
) ) {
return 'quote'; return 'quote';
}
if (kind === 'description' || kind === 'fundamentals') return 'company'; if (kind === 'description' || kind === 'fundamentals') return 'company';
if (['coupons', 'dividends', 'bond-events'].includes(kind)) return 'calendar'; if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata'; return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
(input.kind === 'detail' || input.kind === 'bond-current') (input.kind === 'detail' || input.kind === 'bond-current') ?
? `bond-metadata:${input.uid}` `bond-metadata:${input.uid}` :
: catalogScope(input); catalogScope(input);
} }
const quotationScale = 9; const quotationScale = 9;
@@ -5,13 +5,13 @@ export const catalogSearchLimit = 30;
export function selectCatalogInstruments<T extends TbankShare>( export function selectCatalogInstruments<T extends TbankShare>(
found: T[], found: T[],
query: string, query: string,
instrumentType: 'share' | 'bond', instrumentType: 'bond' | 'share',
): (T & { uid: string })[] { ): (T & { uid: string })[] {
const seen = new Set<string>(); const seen = new Set<string>();
const kind = const kind =
instrumentType === 'share' instrumentType === 'share' ?
? 'INSTRUMENT_TYPE_SHARE' 'INSTRUMENT_TYPE_SHARE' :
: 'INSTRUMENT_TYPE_BOND'; 'INSTRUMENT_TYPE_BOND';
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found return found
.filter( .filter(
@@ -21,17 +21,17 @@ export function selectCatalogInstruments<T extends TbankShare>(
.sort( .sort(
(a, b) => (a, b) =>
Number(b.apiTradeAvailableFlag === true) - Number(b.apiTradeAvailableFlag === true) -
Number(a.apiTradeAvailableFlag === true) || Number(a.apiTradeAvailableFlag === true) ||
Number(b.ticker?.toUpperCase() === query.toUpperCase()) - Number(b.ticker?.toUpperCase() === query.toUpperCase()) -
Number(a.ticker?.toUpperCase() === query.toUpperCase()) || Number(a.ticker?.toUpperCase() === query.toUpperCase()) ||
Number(b.classCode === preferredClass) - Number(b.classCode === preferredClass) -
Number(a.classCode === preferredClass) || Number(a.classCode === preferredClass) ||
a.uid.localeCompare(b.uid), a.uid.localeCompare(b.uid),
) )
.filter((instrument) => { .filter((instrument) => {
const key = instrument.isin?.trim() const key = instrument.isin?.trim() ?
? `isin:${instrument.isin.trim().toUpperCase()}` `isin:${instrument.isin.trim().toUpperCase()}` :
: `uid:${instrument.uid}`; `uid:${instrument.uid}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -1,15 +1,16 @@
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MOSCOW_OFFSET_MS = 10_800_000; const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): string | null { export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > 0 return Number.isFinite(seconds) && seconds > 0 ?
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
: null; null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
@@ -19,7 +20,7 @@ export function moscowDay(iso: string): string {
export function previousClose( export function previousClose(
candles: TbankCandle[], candles: TbankCandle[],
priceAt: string, priceAt: string,
): TbankCandle | null { ): null | TbankCandle {
return ( return (
candles candles
.filter((candle) => { .filter((candle) => {
@@ -32,6 +33,6 @@ export function previousClose(
); );
}) })
.sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ?? .sort((a, b) => Number(b.time?.seconds) - Number(a.time?.seconds))[0] ??
null null
); );
} }
@@ -5,7 +5,6 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
PortfolioDto, PortfolioDto,
@@ -19,7 +18,7 @@ const RETURN_PERCENT_DECIMAL_PLACES = 6;
export function instrumentPosition( export function instrumentPosition(
portfolio: PortfolioDto, portfolio: PortfolioDto,
uid: string, uid: string,
type: 'STOCK' | 'BOND', type: 'BOND' | 'STOCK',
): StockPositionDto { ): StockPositionDto {
const positions = portfolio.positions.filter( const positions = portfolio.positions.filter(
(p) => p.type === type && p.instrumentUid === uid, (p) => p.type === type && p.instrumentUid === uid,
@@ -40,8 +39,8 @@ export function instrumentPosition(
currentValue, currentValue,
absoluteReturn, absoluteReturn,
averagePurchasePrice: averagePurchasePrice:
positive && totalCost positive && totalCost ?
? { {
currency: totalCost.currency, currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -50,15 +49,15 @@ export function instrumentPosition(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} } :
: null, null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({ accounts: positions.map((p) => ({
...p, ...p,
returnPercent: returnPercent:
decimalFromString(p.quantity).value > 0n decimalFromString(p.quantity).value > 0n ?
? percent(p.absoluteReturn, cost(p)) percent(p.absoluteReturn, cost(p)) :
: null, null,
})), })),
isStale: portfolio.isStale, isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt, observedAt: portfolio.lastSuccessfulAt,
@@ -66,8 +65,8 @@ export function instrumentPosition(
} }
function cost(position: PortfolioPositionDto): MoneyDto | null { function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice; const price = position.averagePurchasePrice;
return price return price ?
? { {
currency: price.currency, currency: price.currency,
value: formatDecimal( value: formatDecimal(
multiply( multiply(
@@ -75,16 +74,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
decimalFromString(position.quantity), decimalFromString(position.quantity),
), ),
), ),
} } :
: null; null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency; const currency = values[0]?.currency;
if ( if (
!currency || !currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency) !values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) ) {
return null; return null;
}
return { return {
currency, currency,
value: formatDecimal( value: formatDecimal(
@@ -98,14 +98,15 @@ function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
function percent( function percent(
result: MoneyDto | null, result: MoneyDto | null,
base: MoneyDto | null, base: MoneyDto | null,
): string | null { ): null | string {
if ( if (
!base || !base ||
!result || !result ||
base.currency !== result.currency || base.currency !== result.currency ||
decimalFromString(base.value).value <= 0n decimalFromString(base.value).value <= 0n
) ) {
return null; return null;
}
return formatDecimal( return formatDecimal(
divide( divide(
multiply(decimalFromString(result.value), decimalFromString('100')), multiply(decimalFromString(result.value), decimalFromString('100')),
@@ -1,8 +1,10 @@
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { formatDecimal } from '../../../../../common/decimal';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
BondDto, BondDto,
@@ -32,23 +34,24 @@ export class BondCatalogService {
data: data:
result.data?.map((item) => { result.data?.map((item) => {
const short = toBond(item); const short = toBond(item);
const detail = item.detail const detail = item.detail ?
? toBond({ ...item.detail, uid: item.uid }) toBond({ ...item.detail, uid: item.uid }) :
: short; short;
const last = item.price; const last = item.price;
return { return {
...detail, ...detail,
classCode: detail.classCode ?? short.classCode, classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin, isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable, apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price pricePercent: last?.price ?
? formatDecimal(decimal(last.price)) formatDecimal(decimal(last.price)) :
: null, null,
priceAt: timestamp(last?.time), priceAt: timestamp(last?.time),
}; };
}) ?? null, }) ?? null,
}; };
} }
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
@@ -57,11 +60,12 @@ export class BondCatalogService {
const result = await this.discovery.detail(userId, 'bond', uid, observe); const result = await this.discovery.detail(userId, 'bond', uid, observe);
return { return {
...result, ...result,
data: result.data data: result.data ?
? { ...toBond(result.data), slug: result.data.slug } { ...toBond(result.data), slug: result.data.slug } :
: null, null,
}; };
} }
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
@@ -75,9 +79,9 @@ export class BondCatalogService {
); );
return { return {
...result, ...result,
data: result.data data: result.data ?
? { ...toBond(result.data), slug: result.data.slug } { ...toBond(result.data), slug: result.data.slug } :
: null, null,
}; };
} }
} }
@@ -1,18 +1,19 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { import {
decimalFromString, decimalFromString,
divide,
formatDecimal, formatDecimal,
multiply, multiply,
divide,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { import {
TbankAccessService, TbankAccessService,
type TbankCoupon,
type TbankBond, type TbankBond,
type TbankCoupon,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
PortfolioService,
ForecastService, ForecastService,
PortfolioService,
} from '../../../portfolio/portfolio.api'; } from '../../../portfolio/portfolio.api';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -20,14 +21,14 @@ import {
} from '../../application/catalog-enrichment.service'; } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market'; import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
import { money } from '../infrastructure/bond-money.mapper';
import { import {
BondCouponsDto, BondCouponsDto,
BondCouponsResourceDto, BondCouponsResourceDto,
BondPositionDto,
BondMetricsDto, BondMetricsDto,
BondMetricsResourceDto, BondMetricsResourceDto,
BondPositionDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
const ISO_YEAR_LENGTH = 4; const ISO_YEAR_LENGTH = 4;
const INCOME_HISTORY_YEARS = 5; const INCOME_HISTORY_YEARS = 5;
@@ -40,6 +41,7 @@ export class BondIncomeService {
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
) {} ) {}
async metrics( async metrics(
userId: string, userId: string,
uid: string, uid: string,
@@ -53,9 +55,9 @@ export class BondIncomeService {
observe, observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument ?
? bondMetrics(result.values, result.unavailable, result.observedAt) bondMetrics(result.values, result.unavailable, result.observedAt) :
: null, null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -67,6 +69,7 @@ export class BondIncomeService {
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
}; };
} }
async coupons( async coupons(
userId: string, userId: string,
uid: string, uid: string,
@@ -85,21 +88,22 @@ export class BondIncomeService {
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
}
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
const position = portfolio.data const position = portfolio.data ?
? instrumentPosition(portfolio.data, uid, 'BOND') instrumentPosition(portfolio.data, uid, 'BOND') :
: null; null;
const projected = events const projected = events ?
? bondCoupons( bondCoupons(
events, events,
position, position,
{ from, to, today }, { from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data, !portfolio.data,
) ) :
: undefined; undefined;
const result = await this.forecasts.result({ const result = await this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -110,9 +114,9 @@ export class BondIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
return result.data || !projected return result.data || !projected ?
? result result :
: { {
data: bondCoupons( data: bondCoupons(
events ?? [], events ?? [],
null, null,
@@ -135,19 +139,19 @@ export function bondCoupons(
const { from, to, today } = window; const { from, to, today } = window;
const quantity = const quantity =
position?.accounts.length && position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) position.accounts.every((p) => decimalFromString(p.quantity).value > 0n) ?
? decimalFromString(position.quantity) decimalFromString(position.quantity) :
: null; null;
const coupons = events const coupons = events
.map((event) => { .map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null; const paymentDate = timestamp(event.couponDate)?.slice(0, 10) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null; const recordDate = timestamp(event.fixDate)?.slice(0, 10) ?? null;
const perBond = money(event.payOneBond); const perBond = money(event.payOneBond);
const timing = !paymentDate const timing = !paymentDate ?
? ('undated' as const) ('undated' as const) :
: paymentDate >= today paymentDate >= today ?
? ('upcoming' as const) ('upcoming' as const) :
: ('past' as const); ('past' as const);
return { return {
number: event.couponNumber ?? null, number: event.couponNumber ?? null,
paymentDate, paymentDate,
@@ -161,14 +165,14 @@ export function bondCoupons(
perBond && perBond &&
timing === 'upcoming' && timing === 'upcoming' &&
recordDate && recordDate &&
recordDate >= today recordDate >= today ?
? { {
currency: perBond.currency, currency: perBond.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
} } :
: null, null,
}; };
}) })
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
@@ -214,8 +218,8 @@ export function bondMetrics(
clean && clean &&
coupon.currency === clean.currency && coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= 0n && decimalFromString(coupon.value).value >= 0n &&
decimalFromString(clean.value).value > 0n decimalFromString(clean.value).value > 0n ?
? formatDecimal( formatDecimal(
divide( divide(
multiply( multiply(
decimalFromString(coupon.value), decimalFromString(coupon.value),
@@ -227,14 +231,14 @@ export function bondMetrics(
), ),
COUPON_YIELD_DECIMAL_PLACES, COUPON_YIELD_DECIMAL_PLACES,
), ),
) ) :
: null; null;
const couponType = const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
? next.couponType next.couponType :
: instrument.floatingCouponFlag === true instrument.floatingCouponFlag === true ?
? 'COUPON_TYPE_FLOATING' 'COUPON_TYPE_FLOATING' :
: null; null;
return { return {
creditRating, creditRating,
coupon, coupon,
@@ -1,10 +1,7 @@
import { CandleService } from '../../application/candle.service';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { import {
add, add,
decimalFromString, decimalFromString,
@@ -14,15 +11,19 @@ import {
subtract, subtract,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service';
import {
CatalogEnrichmentService,
type CatalogPayload,
} from '../../application/catalog-enrichment.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { money } from '../infrastructure/bond-money.mapper';
import { import {
BondCandlesQueryDto, BondCandlesQueryDto,
BondQuoteDto, BondQuoteDto,
BondQuoteResourceDto, BondQuoteResourceDto,
} from '../presentation/bond.dto'; } from '../presentation/bond.dto';
import { money } from '../infrastructure/bond-money.mapper';
const PRICE_DECIMAL_PLACES = 9; const PRICE_DECIMAL_PLACES = 9;
@Injectable() @Injectable()
export class BondMarketService { export class BondMarketService {
@@ -30,6 +31,7 @@ export class BondMarketService {
private readonly history: CandleService, private readonly history: CandleService,
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
) {} ) {}
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
@@ -43,12 +45,13 @@ export class BondMarketService {
observe, observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument ?
? bondQuote(result.values, result.unavailable, result.observedAt) bondQuote(result.values, result.unavailable, result.observedAt) :
: null, null,
meta: result.meta, meta: result.meta,
}; };
} }
candles( candles(
userId: string, userId: string,
uid: string, uid: string,
@@ -77,8 +80,8 @@ export function bondQuote(
const cleanPrice = const cleanPrice =
pricePercent !== null && pricePercent !== null &&
nominal && nominal &&
decimalFromString(nominal.value).value > 0n decimalFromString(nominal.value).value > 0n ?
? { {
currency: nominal.currency, currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -90,11 +93,11 @@ export function bondQuote(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} } :
: null; null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency cleanPrice && aci && aci.currency === cleanPrice.currency ?
? { {
currency: cleanPrice.currency, currency: cleanPrice.currency,
value: formatDecimal( value: formatDecimal(
add( add(
@@ -102,8 +105,8 @@ export function bondQuote(
decimalFromString(aci.value), decimalFromString(aci.value),
), ),
), ),
} } :
: null; null;
return { return {
pricePercent, pricePercent,
cleanPrice, cleanPrice,
@@ -112,9 +115,9 @@ export function bondQuote(
priceAt, priceAt,
tradingStatus, tradingStatus,
changePercentagePoints: changePercentagePoints:
last?.price && previous?.close last?.price && previous?.close ?
? formatDecimal(subtract(decimal(last.price), decimal(previous.close))) formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
: null, null,
previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(0, 10) ?? null,
unavailable, unavailable,
observedAt, observedAt,
@@ -1,7 +1,8 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
import { instrumentPosition } from '../../application/instrument-position';
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
jest.mock('../../../../../prisma/prisma.service', () => ({ jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
@@ -122,9 +123,9 @@ describe('bond financial projections', () => {
{ {
couponDate: time('2026-10-10'), couponDate: time('2026-10-10'),
payOneBond: payOneBond:
scenario === 'unknown' scenario === 'unknown' ?
? undefined undefined :
: money('30', scenario === 'currency' ? 'usd' : 'rub'), money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90, couponPeriod: scenario === 'period' ? 0 : 90,
}, },
], ],
@@ -1,10 +1,10 @@
import { formatDecimal } from '../../../../../common/decimal'; import { formatDecimal } from '../../../../../common/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) { export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase(); const currency = value?.currency?.toUpperCase();
return value && currency return value && currency ?
? { currency, value: formatDecimal(decimal(value)) } { currency, value: formatDecimal(decimal(value)) } :
: null; null;
} }
@@ -1,4 +1,5 @@
import { InvestmentReadQueryDto } from '../../../investment-resource.dto'; import type { Request } from 'express';
import { import {
Controller, Controller,
Get, Get,
@@ -9,24 +10,25 @@ import {
UseGuards, UseGuards,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service'; import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service'; import { BondMarketService } from '../application/bond-market.service';
import { import {
BondCandlesResourceDto,
BondCandlesQueryDto, BondCandlesQueryDto,
BondCandlesResourceDto,
BondCouponsResourceDto, BondCouponsResourceDto,
BondResourceDto,
BondMetricsResourceDto, BondMetricsResourceDto,
BondPositionResourceDto, BondPositionResourceDto,
BondQuoteResourceDto, BondQuoteResourceDto,
BondResourceDto,
BondSearchQueryDto, BondSearchQueryDto,
BondSearchResourceDto, BondSearchResourceDto,
BondTickerParamsDto, BondTickerParamsDto,
} from './bond.dto'; } from './bond.dto';
import { BondCatalogService } from '../application/bond-catalog.service';
@ApiTags('bonds') @ApiTags('bonds')
@ApiProblemResponses() @ApiProblemResponses()
@@ -39,6 +41,7 @@ export class BondController {
private readonly market: BondMarketService, private readonly market: BondMarketService,
private readonly income: BondIncomeService, private readonly income: BondIncomeService,
) {} ) {}
@Get(':uid/metrics') @Get(':uid/metrics')
@ApiOkResponse({ type: BondMetricsResourceDto }) @ApiOkResponse({ type: BondMetricsResourceDto })
metrics( metrics(
@@ -48,6 +51,7 @@ export class BondController {
) { ) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
} }
@Get() @Get()
@ApiOkResponse({ type: BondSearchResourceDto }) @ApiOkResponse({ type: BondSearchResourceDto })
search( search(
@@ -60,6 +64,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get('by-ticker/:ticker') @Get('by-ticker/:ticker')
@ApiOkResponse({ type: BondResourceDto }) @ApiOkResponse({ type: BondResourceDto })
getByTicker( getByTicker(
@@ -73,6 +78,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid') @Get(':uid')
@ApiOkResponse({ type: BondResourceDto }) @ApiOkResponse({ type: BondResourceDto })
get( get(
@@ -82,6 +88,7 @@ export class BondController {
) { ) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/quote') @Get(':uid/quote')
@ApiOkResponse({ type: BondQuoteResourceDto }) @ApiOkResponse({ type: BondQuoteResourceDto })
quote( quote(
@@ -91,6 +98,7 @@ export class BondController {
) { ) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe'); return this.market.quote(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/candles') @Get(':uid/candles')
@ApiOkResponse({ type: BondCandlesResourceDto }) @ApiOkResponse({ type: BondCandlesResourceDto })
candles( candles(
@@ -105,6 +113,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid/position') @Get(':uid/position')
@ApiOkResponse({ type: BondPositionResourceDto }) @ApiOkResponse({ type: BondPositionResourceDto })
position( position(
@@ -118,6 +127,7 @@ export class BondController {
query.sync === 'observe', query.sync === 'observe',
); );
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@ApiOkResponse({ type: BondCouponsResourceDto }) @ApiOkResponse({ type: BondCouponsResourceDto })
coupons( coupons(
@@ -1,7 +1,7 @@
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../../../common/money.dto';
import { MoneyDto } from '../../../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import { import {
StockCandlesDto, StockCandlesDto,
StockCandlesQueryDto, StockCandlesQueryDto,
@@ -20,44 +20,61 @@ export class BondDto {
description: 'Unambiguous ticker URL segment, or the exact instrument UID.', description: 'Unambiguous ticker URL segment, or the exact instrument UID.',
}) })
declare slug: string; declare slug: string;
@ApiProperty() declare name: string; @ApiProperty() declare name: string;
@ApiProperty({ type: String, nullable: true }) declare isin: string | null; @ApiProperty({ type: String, nullable: true }) declare isin: null | string;
@ApiProperty({ type: String, nullable: true }) declare classCode: @ApiProperty({ type: String, nullable: true }) declare classCode:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare realExchange: @ApiProperty({ type: String, nullable: true }) declare realExchange:
string | null; null | string;
@ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable: @ApiProperty({ type: Boolean, nullable: true }) declare apiTradeAvailable:
boolean | null; boolean | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: string | null;
@ApiProperty({ type: String, nullable: true }) declare ticker: null | string;
@ApiProperty({ type: String, nullable: true }) declare logoName: @ApiProperty({ type: String, nullable: true }) declare logoName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare currency: @ApiProperty({ type: String, nullable: true }) declare currency:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare sector: string | null;
@ApiProperty({ type: Number, nullable: true }) declare lot: number | null; @ApiProperty({ type: String, nullable: true }) declare sector: null | string;
@ApiProperty({ type: Number, nullable: true }) declare lot: null | number;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare historyFrom: string | null; declare historyFrom: null | string;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare nominal: MoneyDto | null; declare nominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare initialNominal: MoneyDto | null; declare initialNominal: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) declare maturityDate: @ApiProperty({ type: String, nullable: true }) declare maturityDate:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare callDate: @ApiProperty({ type: String, nullable: true }) declare callDate:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare bondType: @ApiProperty({ type: String, nullable: true }) declare bondType:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare riskLevel: @ApiProperty({ type: String, nullable: true }) declare riskLevel:
string | null; null | string;
@ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear: @ApiProperty({ type: Number, nullable: true }) declare couponQuantityPerYear:
number | null; null | number;
@ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon: @ApiProperty({ type: Boolean, nullable: true }) declare floatingCoupon:
boolean | null; boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare perpetual: @ApiProperty({ type: Boolean, nullable: true }) declare perpetual:
boolean | null; boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare amortization: @ApiProperty({ type: Boolean, nullable: true }) declare amortization:
boolean | null; boolean | null;
@ApiProperty({ type: Boolean, nullable: true }) declare subordinated: @ApiProperty({ type: Boolean, nullable: true }) declare subordinated:
boolean | null; boolean | null;
} }
@@ -68,13 +85,15 @@ export class BondSearchResultDto extends BondDto {
nullable: true, nullable: true,
description: 'Clean price in percent of nominal, not money.', description: 'Clean price in percent of nominal, not money.',
}) })
declare pricePercent: string | null; declare pricePercent: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null; declare priceAt: null | string;
} }
export class BondQuoteDto { export class BondQuoteDto {
@ApiProperty({ type: String, nullable: true }) declare pricePercent: @ApiProperty({ type: String, nullable: true }) declare pricePercent:
string | null; null | string;
@ApiProperty({ @ApiProperty({
type: MoneyDto, type: MoneyDto,
nullable: true, nullable: true,
@@ -82,6 +101,7 @@ export class BondQuoteDto {
'Indicative clean value at the current nominal; no FX conversion.', 'Indicative clean value at the current nominal; no FX conversion.',
}) })
declare cleanPrice: MoneyDto | null; declare cleanPrice: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null; @ApiProperty({ type: MoneyDto, nullable: true }) declare aci: MoneyDto | null;
@ApiProperty({ @ApiProperty({
type: MoneyDto, type: MoneyDto,
@@ -90,41 +110,53 @@ export class BondQuoteDto {
'Indicative clean value plus current accrued interest, excluding fees.', 'Indicative clean value plus current accrued interest, excluding fees.',
}) })
declare dirtyPrice: MoneyDto | null; declare dirtyPrice: MoneyDto | null;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare changePercentagePoints: string | null; declare changePercentagePoints: null | string;
@ApiProperty({ type: String, nullable: true }) declare previousCloseDate: @ApiProperty({ type: String, nullable: true }) declare previousCloseDate:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare tradingStatus: @ApiProperty({ type: String, nullable: true }) declare tradingStatus:
string | null; null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare priceAt: string | null; declare priceAt: null | string;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class BondCouponDto { export class BondCouponDto {
@ApiProperty({ type: String, nullable: true }) declare number: string | null; @ApiProperty({ type: String, nullable: true }) declare number: null | string;
@ApiProperty({ type: String, nullable: true }) declare paymentDate: @ApiProperty({ type: String, nullable: true }) declare paymentDate:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare recordDate: @ApiProperty({ type: String, nullable: true }) declare recordDate:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare couponType: @ApiProperty({ type: String, nullable: true }) declare couponType:
string | null; null | string;
@ApiProperty({ type: Number, nullable: true }) declare periodDays: @ApiProperty({ type: Number, nullable: true }) declare periodDays:
number | null; null | number;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare perBond: MoneyDto | null; declare perBond: MoneyDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare estimatedAmount: MoneyDto | null; declare estimatedAmount: MoneyDto | null;
@ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing: @ApiProperty({ enum: ['upcoming', 'past', 'undated'] }) declare timing:
'upcoming' | 'past' | 'undated'; 'past' | 'undated' | 'upcoming';
} }
export class BondCouponsDto { export class BondCouponsDto {
@ApiProperty({ type: [BondCouponDto] }) declare coupons: BondCouponDto[]; @ApiProperty({ type: BondCouponDto, isArray: true }) declare coupons: BondCouponDto[];
@ApiProperty() declare from: string; @ApiProperty() declare from: string;
@ApiProperty() declare to: string; @ApiProperty() declare to: string;
@ApiProperty() declare observedAt: string; @ApiProperty() declare observedAt: string;
@ApiProperty({ type: String, nullable: true }) declare positionAt: @ApiProperty({ type: String, nullable: true }) declare positionAt:
string | null; null | string;
@ApiProperty() declare estimateUnavailable: boolean; @ApiProperty() declare estimateUnavailable: boolean;
@ApiProperty() declare positionIsStale: boolean; @ApiProperty() declare positionIsStale: boolean;
} }
@@ -132,6 +164,7 @@ export class BondCouponsDto {
export class BondYieldDto { export class BondYieldDto {
@ApiProperty({ description: 'MOEX effective annual yield in percent.' }) @ApiProperty({ description: 'MOEX effective annual yield in percent.' })
declare value: string; declare value: string;
@ApiProperty() declare pricePercent: string; @ApiProperty() declare pricePercent: string;
@ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ @ApiProperty({
@@ -139,14 +172,16 @@ export class BondYieldDto {
nullable: true, nullable: true,
description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.', description: 'MOEX yield target, e.g. MATDATE or OFFERDATE.',
}) })
declare dateType: string | null; declare dateType: null | string;
@ApiProperty({ format: 'date-time' }) declare tradeAt: string; @ApiProperty({ format: 'date-time' }) declare tradeAt: string;
} }
export class BondCreditRatingDto { export class BondCreditRatingDto {
@ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR'; @ApiProperty({ enum: ['NCR'] }) declare agency: 'NCR';
@ApiProperty({ type: String, nullable: true }) declare value: string | null; @ApiProperty({ type: String, nullable: true }) declare value: null | string;
@ApiProperty({ enum: ['active', 'withdrawn'] }) declare status: @ApiProperty({ enum: ['active', 'withdrawn'] }) declare status:
'active' | 'withdrawn'; 'active' | 'withdrawn';
@ApiProperty({ format: 'date' }) declare date: string; @ApiProperty({ format: 'date' }) declare date: string;
@ApiProperty({ format: 'uri' }) declare sourceUrl: string; @ApiProperty({ format: 'uri' }) declare sourceUrl: string;
@ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
@@ -154,39 +189,50 @@ export class BondCreditRatingDto {
export class BondMetricsDto { export class BondMetricsDto {
@ApiProperty({ type: BondCreditRatingDto, nullable: true }) @ApiProperty({ type: BondCreditRatingDto, nullable: true })
declare creditRating: BondCreditRatingDto | null; declare creditRating: BondCreditRatingDto | null;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare coupon: MoneyDto | null; declare coupon: MoneyDto | null;
@ApiProperty({ type: String, nullable: true, format: 'date' }) @ApiProperty({ type: String, nullable: true, format: 'date' })
declare couponDate: string | null; declare couponDate: null | string;
@ApiProperty({ type: String, nullable: true }) declare couponType: @ApiProperty({ type: String, nullable: true }) declare couponType:
string | null; null | string;
@ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays: @ApiProperty({ type: Number, nullable: true }) declare couponPeriodDays:
number | null; null | number;
@ApiProperty({ @ApiProperty({
type: String, type: String,
nullable: true, nullable: true,
description: description:
'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.', 'Next coupon × 365 / period days / indicative clean price × 100. Not yield to maturity.',
}) })
declare couponYieldPercent: string | null; declare couponYieldPercent: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' }) @ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare couponYieldPriceAt: string | null; declare couponYieldPriceAt: null | string;
@ApiProperty({ type: BondYieldDto, nullable: true }) @ApiProperty({ type: BondYieldDto, nullable: true })
declare effectiveYield: BondYieldDto | null; declare effectiveYield: BondYieldDto | null;
@ApiProperty({ type: [String] }) declare unavailable: string[];
@ApiProperty({ type: String, isArray: true }) declare unavailable: string[];
@ApiProperty({ format: 'date-time' }) declare observedAt: string; @ApiProperty({ format: 'date-time' }) declare observedAt: string;
} }
export class BondPositionResourceDto { export class BondPositionResourceDto {
@ApiProperty({ type: BondPositionDto, nullable: true }) @ApiProperty({ type: BondPositionDto, nullable: true })
declare data: BondPositionDto | null; declare data: BondPositionDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
export class BondSearchResourceDto { export class BondSearchResourceDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
@ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true }) @ApiProperty({ type: BondSearchResultDto, isArray: true, nullable: true })
declare data: BondSearchResultDto[] | null; declare data: BondSearchResultDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -200,6 +246,7 @@ export class BondResourceDto {
export class BondQuoteResourceDto { export class BondQuoteResourceDto {
@ApiProperty({ type: BondQuoteDto, nullable: true }) @ApiProperty({ type: BondQuoteDto, nullable: true })
declare data: BondQuoteDto | null; declare data: BondQuoteDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -207,6 +254,7 @@ export class BondQuoteResourceDto {
export class BondMetricsResourceDto { export class BondMetricsResourceDto {
@ApiProperty({ type: BondMetricsDto, nullable: true }) @ApiProperty({ type: BondMetricsDto, nullable: true })
declare data: BondMetricsDto | null; declare data: BondMetricsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -214,6 +262,7 @@ export class BondMetricsResourceDto {
export class BondCandlesResourceDto { export class BondCandlesResourceDto {
@ApiProperty({ type: BondCandlesDto, nullable: true }) @ApiProperty({ type: BondCandlesDto, nullable: true })
declare data: BondCandlesDto | null; declare data: BondCandlesDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -221,6 +270,7 @@ export class BondCandlesResourceDto {
export class BondCouponsResourceDto { export class BondCouponsResourceDto {
@ApiProperty({ type: BondCouponsDto, nullable: true }) @ApiProperty({ type: BondCouponsDto, nullable: true })
declare data: BondCouponsDto | null; declare data: BondCouponsDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
@@ -67,7 +67,7 @@ describe('source-confirmed daily candle coverage', () => {
expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual( expect(window.to.valueOf() - window.from.valueOf()).toBeLessThanOrEqual(
365 * 86400000, 365 * 86400000,
); );
if (index) expect(window.from).toEqual(windows[index - 1].to); expect(window.from).toEqual(index ? windows[index - 1].to : range.from);
}); });
}); });
}); });
@@ -2,13 +2,13 @@ import { MILLISECONDS_PER_DAY } from '../../../../common/time';
export type CandleRange = { from: Date; to: Date }; export type CandleRange = { from: Date; to: Date };
export type CandleCoverage = CandleRange & { export type CandleCoverage = CandleRange & {
isComplete: boolean;
fetchedAt: Date; fetchedAt: Date;
isComplete: boolean;
}; };
export type SavedCandle = { export type SavedCandle = {
time: string;
close: string; close: string;
isComplete: boolean; isComplete: boolean;
time: string;
}; };
const windowDays = 365; const windowDays = 365;
const tailFreshForMs = 60_000; const tailFreshForMs = 60_000;
@@ -30,14 +30,15 @@ export function candleGaps(
!covered.isComplete && !covered.isComplete &&
(now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs || (now.valueOf() - covered.fetchedAt.valueOf() >= tailFreshForMs ||
utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf()) utcDay(now).valueOf() !== utcDay(covered.fetchedAt).valueOf())
) ) {
continue; continue;
}
gaps = gaps.flatMap((gap) => { gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [ return [
...(covered.from > gap.from ...(covered.from > gap.from ?
? [{ from: gap.from, to: covered.from }] [{ from: gap.from, to: covered.from }] :
: []), []),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
]; ];
}); });
@@ -73,11 +74,14 @@ export function confirmedCandleCoverage(
for (const boundary of [ for (const boundary of [
midnight, midnight,
...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]), ...[...incompleteDays].flatMap((day) => [day, day + MILLISECONDS_PER_DAY]),
]) ]) {
if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) if (boundary > range.from.valueOf() && boundary < range.to.valueOf()) {
boundaries.add(boundary); boundaries.add(boundary);
}
}
const sorted = [...boundaries].sort((a, b) => a - b); const sorted = [...boundaries].sort((a, b) => a - b);
return sorted.slice(1).map((to, index) => { return sorted.slice(1).map((to, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the sorted numeric boundary array.
const from = sorted[index]; const from = sorted[index];
return { return {
from: new Date(from), from: new Date(from),
@@ -1,24 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
confirmedCandleCoverage,
type CandleRange, type CandleRange,
confirmedCandleCoverage,
type SavedCandle, type SavedCandle,
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
const provider = 'tbank'; const provider = 'tbank';
const interval = 'day'; const interval = 'day';
type CandleIntent = { type CandleIntent = {
instrumentType?: 'share' | 'bond';
uid: string;
ranges: CandleRange[];
period?: string;
now?: string;
force?: boolean; force?: boolean;
instrumentType?: 'bond' | 'share';
now?: string;
period?: string;
ranges: CandleRange[];
uid: string;
}; };
@Injectable() @Injectable()
export class CandleRepository { export class CandleRepository {
@@ -57,7 +58,7 @@ export class CandleRepository {
} }
reserve( reserve(
owner: { userId: string; revision: string }, owner: { revision: string; userId: string },
scopeKey: string, scopeKey: string,
intent: CandleIntent, intent: CandleIntent,
runId: string, runId: string,
@@ -88,29 +89,31 @@ export class CandleRepository {
(!intent.force || (!intent.force ||
(previousIntent.force === true && (previousIntent.force === true &&
(!intent.now || intent.now <= previousNow))) (!intent.now || intent.now <= previousNow)))
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const work = await tx.tbankSyncWork.update({ const work = await tx.tbankSyncWork.update({
where: { id: previous.id }, where: { id: previous.id },
data: { data: {
priority: nextPriority, priority: nextPriority,
intent: { intent: {
...previousIntent, ...previousIntent,
...(intent.force ...(intent.force ?
? { {
force: true, force: true,
...(intent.now && intent.now > previousNow ...(intent.now && intent.now > previousNow ?
? { now: intent.now } { now: intent.now } :
: {}), {}),
} } :
: {}), {}),
}, },
}, },
}); });
return { work, launch: false }; return { work, launch: false };
} }
if (previous) if (previous) {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } }); await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId: runId, processRunId: runId,
@@ -142,12 +145,15 @@ export class CandleRepository {
fetchedAt: Date, fetchedAt: Date,
) { ) {
// Reject malformed financial input instead of publishing a false empty range. // Reject malformed financial input instead of publishing a false empty range.
for (const candle of candles) for (const candle of candles) {
if ( if (
!Number.isFinite(Date.parse(candle.time)) || !Number.isFinite(Date.parse(candle.time)) ||
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
!/^-?\d+(?:\.\d+)?$/.test(candle.close) !/^-?\d+(?:\.\d+)?$/.test(candle.close)
) ) {
throw new Error('Invalid candle'); throw new Error('Invalid candle');
}
}
const key = `${range.from.toISOString()}/${range.to.toISOString()}`; const key = `${range.from.toISOString()}/${range.to.toISOString()}`;
const rows = [ const rows = [
...new Map( ...new Map(
@@ -155,7 +161,7 @@ export class CandleRepository {
.filter( .filter(
(candle) => (candle) =>
new Date(candle.time) >= range.from && new Date(candle.time) >= range.from &&
new Date(candle.time) < range.to, new Date(candle.time) < range.to,
) )
.map((candle) => [candle.time, candle]), .map((candle) => [candle.time, candle]),
).values(), ).values(),
@@ -222,8 +228,9 @@ export class CandleRepository {
`${range.from.toISOString()}/${range.to.toISOString()}`, `${range.from.toISOString()}/${range.to.toISOString()}`,
), ),
) )
) ) {
throw new Error('Incomplete candle reconciliation'); throw new Error('Incomplete candle reconciliation');
}
const identity = { provider, instrumentUid: intent.uid, interval }; const identity = { provider, instrumentUid: intent.uid, interval };
for (const range of intent.ranges) { for (const range of intent.ranges) {
const row = staged.find( const row = staged.find(
@@ -254,21 +261,22 @@ export class CandleRepository {
// Preserve coverage outside the requested reconciliation boundaries. // Preserve coverage outside the requested reconciliation boundaries.
for (const previous of old) { for (const previous of old) {
for (const part of [ for (const part of [
...(previous.from < range.from ...(previous.from < range.from ?
? [{ from: previous.from, to: range.from }] [{ from: previous.from, to: range.from }] :
: []), []),
...(previous.to > range.to ...(previous.to > range.to ?
? [{ from: range.to, to: previous.to }] [{ from: range.to, to: previous.to }] :
: []), []),
]) ]) {
await tx.tbankCandleCoverage.create({ await tx.tbankCandleCoverage.create({
data: { ...previous, ...part }, data: { ...previous, ...part },
}); });
}
} }
await tx.tbankCandle.deleteMany({ await tx.tbankCandle.deleteMany({
where: { ...identity, time: { gte: range.from, lt: range.to } }, where: { ...identity, time: { gte: range.from, lt: range.to } },
}); });
for (const candle of payload.candles) for (const candle of payload.candles) {
await tx.tbankCandle.create({ await tx.tbankCandle.create({
data: { data: {
...identity, ...identity,
@@ -279,14 +287,16 @@ export class CandleRepository {
fetchedAt, fetchedAt,
}, },
}); });
}
for (const covered of confirmedCandleCoverage( for (const covered of confirmedCandleCoverage(
range, range,
payload.candles, payload.candles,
fetchedAt, fetchedAt,
)) )) {
await tx.tbankCandleCoverage.create({ await tx.tbankCandleCoverage.create({
data: { ...identity, ...covered }, data: { ...identity, ...covered },
}); });
}
} }
await tx.tbankSyncStage.deleteMany({ await tx.tbankSyncStage.deleteMany({
where: { workId: attempt.id, attemptId: attempt.attemptId }, where: { workId: attempt.id, attemptId: attempt.attemptId },
@@ -1,40 +1,42 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import type { import type {
TbankBond, TbankBond,
TbankShare, TbankShare,
TbankSyncAttempt, TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
export type CatalogQuery = { export type CatalogQuery = {
instrumentType: 'share' | 'bond';
query: string;
exactTicker?: boolean; exactTicker?: boolean;
instrumentType: 'bond' | 'share';
query: string;
}; };
export type CatalogSource = { export type CatalogSource = {
instrumentType: 'share' | 'bond'; instrumentType: 'bond' | 'share';
uid: string;
kind: kind:
| 'detail' | 'bond-current' |
| 'last-price' 'bond-events' |
| 'bond-current' 'comparison' |
| 'description' 'coupons' |
| 'fundamentals' 'description' |
| 'trading-status' 'detail' |
| 'comparison' 'dividends' |
| 'coupons' 'fundamentals' |
| 'yield' 'last-price' |
| 'rating' 'rating' |
| 'dividends' 'trading-status' |
| 'bond-events'; 'yield';
rangeKey?: string; rangeKey?: string;
ticker?: string; ticker?: string;
uid: string;
}; };
export type CatalogWork = CatalogQuery | CatalogSource; export type CatalogWork = CatalogQuery | CatalogSource;
export type CatalogInstrument = (TbankShare | TbankBond) & { uid: string }; export type CatalogInstrument = (TbankBond | TbankShare) & { uid: string };
const provider = 'tbank'; const provider = 'tbank';
export function catalogQuery(input: CatalogQuery): CatalogQuery { export function catalogQuery(input: CatalogQuery): CatalogQuery {
return { return {
@@ -44,13 +46,14 @@ export function catalogQuery(input: CatalogQuery): CatalogQuery {
}; };
} }
export function catalogScope(input: CatalogWork) { export function catalogScope(input: CatalogWork) {
if ('uid' in input) if ('uid' in input) {
return `catalog-source:${JSON.stringify([ return `catalog-source:${JSON.stringify([
input.kind, input.kind,
input.kind === 'detail' ? input.instrumentType : null, input.kind === 'detail' ? input.instrumentType : null,
input.rangeKey ?? '', input.rangeKey ?? '',
input.uid, input.uid,
])}`; ])}`;
}
return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`; return `${input.exactTicker ? 'ticker' : 'catalog'}:${JSON.stringify([input.instrumentType, input.query])}`;
} }
@@ -60,8 +63,8 @@ export class CatalogDiscoveryRepository {
read(input: CatalogQuery) { read(input: CatalogQuery) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const alias = input.exactTicker const alias = input.exactTicker ?
? await tx.tbankInstrumentLookup.findUnique({ await tx.tbankInstrumentLookup.findUnique({
where: { where: {
provider_identifierType_identifier: { provider_identifierType_identifier: {
provider, provider,
@@ -69,17 +72,17 @@ export class CatalogDiscoveryRepository {
identifier: input.query, identifier: input.query,
}, },
}, },
}) }) :
: null; null;
const discovery = input.exactTicker const discovery = input.exactTicker ?
? alias alias ?
? { {
instrumentUids: alias.matchedUids, instrumentUids: alias.matchedUids,
fetchedAt: alias.fetchedAt, fetchedAt: alias.fetchedAt,
dataRevision: alias.dataRevision, dataRevision: alias.dataRevision,
} } :
: null null :
: await tx.tbankCatalogDiscovery.findUnique({ await tx.tbankCatalogDiscovery.findUnique({
where: { where: {
provider_instrumentType_query: { provider_instrumentType_query: {
provider, provider,
@@ -101,12 +104,13 @@ export class CatalogDiscoveryRepository {
}); });
const byUid = new Map(rows.map((row) => [row.instrumentUid, row])); const byUid = new Map(rows.map((row) => [row.instrumentUid, row]));
// A damaged/missing dependency is unknown, never a confirmed empty search. // A damaged/missing dependency is unknown, never a confirmed empty search.
if (uids.some((uid) => !byUid.has(uid))) if (uids.some((uid) => !byUid.has(uid))) {
return { return {
data: null, data: null,
fetchedAt: null, fetchedAt: null,
dataRevision: String(discovery.dataRevision), dataRevision: String(discovery.dataRevision),
}; };
}
return { return {
data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument), data: uids.map((uid) => byUid.get(uid)?.payload as CatalogInstrument),
fetchedAt: discovery.fetchedAt, fetchedAt: discovery.fetchedAt,
@@ -127,9 +131,9 @@ export class CatalogDiscoveryRepository {
kind: input.kind, kind: input.kind,
source: sourceProvider(input), source: sourceProvider(input),
rangeKey: rangeKey:
input.kind === 'detail' input.kind === 'detail' ?
? input.instrumentType input.instrumentType :
: (input.rangeKey ?? ''), (input.rangeKey ?? ''),
})), })),
}, },
}); });
@@ -176,7 +180,7 @@ export class CatalogDiscoveryRepository {
}, },
}); });
const instrument = ( const instrument = (
value as { instrument?: TbankShare | TbankBond | null } value as { instrument?: null | TbankBond | TbankShare }
).instrument; ).instrument;
if ( if (
(input.kind === 'detail' || input.kind === 'bond-current') && (input.kind === 'detail' || input.kind === 'bond-current') &&
@@ -225,9 +229,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previousAlias?.matchedUids, previousAlias?.matchedUids,
matchedUids, matchedUids,
) ) ?
? 0 0 :
: 1, 1,
}, },
}, },
}); });
@@ -239,9 +243,9 @@ export class CatalogDiscoveryRepository {
source: sourceProvider(input), source: sourceProvider(input),
kind: input.kind, kind: input.kind,
rangeKey: rangeKey:
input.kind === 'detail' input.kind === 'detail' ?
? input.instrumentType input.instrumentType :
: (input.rangeKey ?? ''), (input.rangeKey ?? ''),
}; };
const previous = await tx.tbankInstrumentSource.findUnique({ const previous = await tx.tbankInstrumentSource.findUnique({
where: { provider_instrumentUid_source_kind_rangeKey: key }, where: { provider_instrumentUid_source_kind_rangeKey: key },
@@ -262,7 +266,7 @@ export class CatalogDiscoveryRepository {
} }
reserve( reserve(
owner: { userId: string; revision: string }, owner: { revision: string; userId: string },
input: CatalogWork, input: CatalogWork,
runId: string, runId: string,
active: ReadonlySet<string>, active: ReadonlySet<string>,
@@ -281,8 +285,9 @@ export class CatalogDiscoveryRepository {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === runId && previous.processRunId === runId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId: runId, processRunId: runId,
@@ -351,9 +356,9 @@ export class CatalogDiscoveryRepository {
instrumentKind: instrument.instrumentKind, instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency, currency: instrument.currency,
brand: instrument.brand brand: instrument.brand ?
? { logoName: instrument.brand.logoName } { logoName: instrument.brand.logoName } :
: undefined, undefined,
}), }),
) as Prisma.InputJsonObject; ) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({ await tx.tbankInstrument.upsert({
@@ -410,9 +415,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.matchedUids, previous?.matchedUids,
instrumentUids, instrumentUids,
) ) ?
? 0 0 :
: 1, 1,
}, },
}, },
}); });
@@ -436,9 +441,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.instrumentUids, previous?.instrumentUids,
instrumentUids, instrumentUids,
) ) ?
? 0 0 :
: 1, 1,
}, },
}, },
}); });
@@ -472,9 +477,9 @@ export class CatalogDiscoveryRepository {
} }
function sourceProvider(input: CatalogSource) { function sourceProvider(input: CatalogSource) {
return input.kind === 'yield' || input.kind === 'bond-events' return input.kind === 'yield' || input.kind === 'bond-events' ?
? 'moex' 'moex' :
: input.kind === 'rating' input.kind === 'rating' ?
? 'ncr' 'ncr' :
: 'tbank'; 'tbank';
} }
@@ -1,2 +1,2 @@
export { StockIncomeService } from './stocks/application/stock-income.service';
export { BondIncomeService } from './bonds/application/bond-income.service'; export { BondIncomeService } from './bonds/application/bond-income.service';
export { StockIncomeService } from './stocks/application/stock-income.service';
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module'; import { MoexModule } from '../../../integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { NcrModule } from '../../../integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { CandleService } from './application/candle.service'; import { CandleService } from './application/candle.service';
import { CandleRepository } from './infrastructure/candle.repository';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
import { CatalogDiscoveryService } from './application/catalog-discovery.service'; import { CatalogDiscoveryService } from './application/catalog-discovery.service';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
import { CandleRepository } from './infrastructure/candle.repository';
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository'; import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
@Module({ @Module({
@@ -1,5 +1,5 @@
export { CandleService } from './application/candle.service'; export { CandleService } from './application/candle.service';
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
export type { CatalogPayload } from './application/catalog-enrichment.service';
export { CatalogDiscoveryService } from './application/catalog-discovery.service'; export { CatalogDiscoveryService } from './application/catalog-discovery.service';
export { CatalogEnrichmentService } from './application/catalog-enrichment.service';
export type { CatalogPayload } from './application/catalog-enrichment.service';
@@ -1,17 +1,18 @@
import { InstrumentSourcesModule } from './instrument-sources.module';
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondCatalogService } from './bonds/application/bond-catalog.service';
import { BondIncomeService } from './bonds/application/bond-income.service'; import { BondIncomeService } from './bonds/application/bond-income.service';
import { BondMarketService } from './bonds/application/bond-market.service'; import { BondMarketService } from './bonds/application/bond-market.service';
import { BondController } from './bonds/presentation/bond.controller'; import { BondController } from './bonds/presentation/bond.controller';
import { BondCatalogService } from './bonds/application/bond-catalog.service'; import { InstrumentSourcesModule } from './instrument-sources.module';
import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case'; import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.use-case';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service'; import { StockMarketService } from './stocks/application/stock-market.service';
import { StockController } from './stocks/presentation/stock.controller'; import { StockController } from './stocks/presentation/stock.controller';
import { StockCatalogService } from './stocks/application/stock-catalog.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule],
@@ -1,5 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { import {
StockCompanyResourceDto, StockCompanyResourceDto,
@@ -21,8 +23,8 @@ export class GetStockCompanyUseCase {
observe, observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument ?
? { {
description: result.values.description ?? null, description: result.values.description ?? null,
metrics: metrics( metrics: metrics(
result.values.fundamentals ?? null, result.values.fundamentals ?? null,
@@ -30,14 +32,14 @@ export class GetStockCompanyUseCase {
), ),
unavailable: result.unavailable, unavailable: result.unavailable,
observedAt: result.observedAt, observedAt: result.observedAt,
} } :
: null, null,
meta: result.meta, meta: result.meta,
}; };
} }
} }
export function metrics( export function metrics(
values: TbankFundamentals | null, values: null | TbankFundamentals,
sector?: string, sector?: string,
): StockMetricDto[] { ): StockMetricDto[] {
if (!values) return []; if (!values) return [];
@@ -49,30 +51,31 @@ export function metrics(
][] = [ ][] = [
['marketCapitalization', 'money', null], ['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'], ['peRatioTtm', 'ratio', 'TTM'],
...(bank ...(bank ?
? ([ ([
['priceToBookTtm', 'ratio', 'TTM'], ['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null], ['roe', 'percent', null],
] as typeof selection) ] as typeof selection) :
: ([ ([
['revenueTtm', 'money', 'TTM'], ['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)), ] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'], ['netIncomeTtm', 'money', 'TTM'],
...(!bank ...(!bank ?
? ([ ([
['totalDebtMrq', 'money', 'MRQ'], ['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null], ['netDebtToEbitda', 'ratio', null],
] as typeof selection) ] as typeof selection) :
: []), []),
]; ];
return selection.flatMap(([key, unit, period]) => { return selection.flatMap(([key, unit, period]) => {
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
const value = values[key]; const value = values[key];
const currency = values.currency?.toUpperCase() || null; const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' && return typeof value === 'number' &&
Number.isFinite(value) && Number.isFinite(value) &&
(unit !== 'money' || currency) (unit !== 'money' || currency) ?
? [ [
{ {
key, key,
value, value,
@@ -80,7 +83,7 @@ export function metrics(
period, period,
currency: unit === 'money' ? currency : null, currency: unit === 'money' ? currency : null,
}, },
] ] :
: []; [];
}); });
} }

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