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