Merge pull request #42 from codex/backend-eslint
This commit was merged in pull request #42.
This commit is contained in:
+5
-2
@@ -267,5 +267,8 @@ Tests сохраняют независимые literal wire assertions; обы
|
|||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
controllers делегируют application, use cases не импортируют Prisma. Чистые
|
||||||
|
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
|
||||||
|
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
|
||||||
|
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
|
||||||
|
|||||||
@@ -150,6 +150,10 @@ lint. Он не защищает от усложнения уже учтённо
|
|||||||
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
||||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||||
|
|
||||||
|
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||||
|
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||||
|
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||||
|
|
||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import globals from 'globals';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||||
|
const prismaImports = [
|
||||||
|
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||||
@@ -119,6 +122,52 @@ export default defineConfig(
|
|||||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/modules/**/domain/**/*.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: [
|
||||||
|
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
|
||||||
|
'**/*.client', '**/application/**', '**/infrastructure/**',
|
||||||
|
],
|
||||||
|
message: 'Domain uses pure rules and value types, not transport or persistence.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.controller.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [...prismaImports, '**/*.repository', '**/*.client'],
|
||||||
|
message: 'Controllers delegate to application services or use cases.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['**'],
|
||||||
|
importNamePattern: '(?:Client|Repository|PrismaService)$',
|
||||||
|
message: 'Use application orchestration rather than data providers in controllers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.use-case.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: prismaImports,
|
||||||
|
message: 'Use cases obtain data through concrete repositories.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
|
|||||||
Generated
+45
-214
@@ -9,7 +9,7 @@
|
|||||||
"version": "0.2.3",
|
"version": "0.2.3",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -1425,29 +1425,6 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -1536,9 +1513,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.14.4",
|
"version": "1.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
|
||||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
@@ -2797,29 +2774,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -5095,29 +5049,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -6270,6 +6201,16 @@
|
|||||||
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -6304,9 +6245,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-ftp": {
|
"node_modules/basic-ftp": {
|
||||||
"version": "5.3.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6415,6 +6356,19 @@
|
|||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||||
@@ -6506,6 +6460,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/builtin-modules": {
|
"node_modules/builtin-modules": {
|
||||||
@@ -7019,6 +6974,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"dev": true,
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 6.0"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
@@ -8358,29 +8314,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8587,29 +8520,6 @@
|
|||||||
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8658,29 +8568,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
@@ -9053,9 +8940,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9537,29 +9424,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9967,9 +9831,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.0",
|
"version": "10.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -11641,14 +11505,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
|
||||||
"type-is": "^1.6.18"
|
"type-is": "^1.6.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -13213,16 +13076,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/release-it/node_modules/undici": {
|
|
||||||
"version": "7.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/remeda": {
|
"node_modules/remeda": {
|
||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
@@ -14181,29 +14034,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/glob": {
|
"node_modules/test-exclude/node_modules/glob": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
@@ -14234,9 +14064,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14633,6 +14463,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
+6
-2
@@ -37,7 +37,7 @@
|
|||||||
"test:lint": "node --test test/eslint-config.cjs"
|
"test:lint": "node --test test/eslint-config.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -105,11 +105,15 @@
|
|||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.3.0"
|
"multer": "2.4.0"
|
||||||
},
|
},
|
||||||
"@prisma/config@7.10.0": {
|
"@prisma/config@7.10.0": {
|
||||||
"deepmerge-ts": "8.0.2"
|
"deepmerge-ts": "8.0.2"
|
||||||
},
|
},
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.1",
|
||||||
|
"undici": "7.29.1"
|
||||||
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"mysql2": "3.24.4"
|
"mysql2": "3.24.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { HttpErrorCode } from '../../../common/error-code';
|
|||||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AuthErrorCode } from './auth-error-code';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ export class AuthService {
|
|||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
let payload: RefreshPayload;
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
@@ -151,7 +151,7 @@ export class AuthService {
|
|||||||
|
|
||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -166,7 +166,7 @@ export class AuthService {
|
|||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
expectedRefreshTokenHash?: string,
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: session.userId,
|
sub: session.userId,
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface AuthUser {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthenticationResult = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sid: string;
|
sid: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ import {
|
|||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { CredentialsDto } from './auth.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
@@ -117,11 +117,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private respond(
|
private respond(
|
||||||
response: Response,
|
response: Response,
|
||||||
result: {
|
result: AuthenticationResult,
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: { email: string; id: string };
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
return { user: result.user, accessToken: result.accessToken };
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
|||||||
@@ -103,13 +103,9 @@ export class BankCardsService {
|
|||||||
id,
|
id,
|
||||||
name: dto.name ?? previous.name,
|
name: dto.name ?? previous.name,
|
||||||
bankName:
|
bankName:
|
||||||
dto.bankName === undefined
|
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||||
? (previous.bankName ?? undefined)
|
|
||||||
: (dto.bankName ?? undefined),
|
|
||||||
lastFour:
|
lastFour:
|
||||||
dto.lastFour === undefined
|
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||||
? (previous.lastFour ?? undefined)
|
|
||||||
: (dto.lastFour ?? undefined),
|
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
dto.paymentSystem === undefined
|
dto.paymentSystem === undefined
|
||||||
? previous.paymentSystem
|
? previous.paymentSystem
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -26,6 +24,7 @@ import {
|
|||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
@@ -413,17 +412,7 @@ export class CandleService {
|
|||||||
window.from,
|
window.from,
|
||||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
);
|
);
|
||||||
const rows = candles.flatMap((candle) => {
|
const rows = candles.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) throw new Error('Invalid candle response');
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -37,6 +35,7 @@ import {
|
|||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
|
||||||
@@ -353,17 +352,7 @@ export class CatalogEnrichmentService {
|
|||||||
gap.from,
|
gap.from,
|
||||||
gap.to,
|
gap.to,
|
||||||
);
|
);
|
||||||
const rows = response.map((candle) => {
|
const rows = response.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
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');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { toSavedCandle } from './tbank-candle.mapper';
|
||||||
|
|
||||||
|
describe('T-Bank candle mapping', () => {
|
||||||
|
it('keeps signed nano precision and requires explicit completeness', () => {
|
||||||
|
const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } };
|
||||||
|
expect(toSavedCandle(input)).toEqual({
|
||||||
|
time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false,
|
||||||
|
});
|
||||||
|
expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true);
|
||||||
|
expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } });
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ close: { units: '1' } },
|
||||||
|
{ time: { seconds: 'invalid' }, close: { units: '1' } },
|
||||||
|
{ time: { seconds: '1767225600' } },
|
||||||
|
])('rejects missing or malformed source fields: %j', (input) => {
|
||||||
|
expect(() => toSavedCandle(input)).toThrow('Invalid candle response');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { SavedCandle } from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../application/instrument-market';
|
||||||
|
|
||||||
|
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toSavedCandle };
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
} from '../../investment-sync-plan';
|
} from '../../investment-sync-plan';
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
|
confirmedOperationRanges,
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
@@ -196,22 +197,7 @@ export class BrokerageOperationsService {
|
|||||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||||
movingEnd,
|
movingEnd,
|
||||||
});
|
});
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
||||||
(saved?.coverage ?? [])
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
(!row.instrumentUid ||
|
|
||||||
row.instrumentUid === target.instrumentUid) &&
|
|
||||||
row.from < target.to &&
|
|
||||||
target.from < row.to,
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const missing = uncoveredOperationIntervals(
|
const missing = uncoveredOperationIntervals(
|
||||||
plan.intervals,
|
plan.intervals,
|
||||||
confirmed.map((range) => ({
|
confirmed.map((range) => ({
|
||||||
@@ -544,19 +530,7 @@ export class BrokerageOperationsService {
|
|||||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||||
(complete ? saved?.connection.membershipAt : null) ??
|
(complete ? saved?.connection.membershipAt : null) ??
|
||||||
null;
|
null;
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, sources);
|
||||||
sources
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
operationIntervalsOverlap(row, target),
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const meta = investmentResourceMeta({
|
const meta = investmentResourceMeta({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
||||||
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
import { operationIntervals, planOperationRead } from './operations-sync-plan';
|
import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan';
|
||||||
|
|
||||||
|
describe('confirmed operation range projection', () => {
|
||||||
|
it('clips overlaps in source order without merging and respects account and instrument scope', () => {
|
||||||
|
const day = (value: number) => new Date(Date.UTC(2026, 0, value));
|
||||||
|
const target = { accountId: 'a', from: day(2), to: day(5) };
|
||||||
|
const sources = [
|
||||||
|
{ ...target, from: day(3), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(4) },
|
||||||
|
{ ...target, instrumentUid: 'uid' },
|
||||||
|
{ ...target, accountId: 'other' },
|
||||||
|
{ ...target, from: day(5), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(2) },
|
||||||
|
];
|
||||||
|
const before = structuredClone(sources);
|
||||||
|
expect(confirmedOperationRanges([target], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
]);
|
||||||
|
expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
target,
|
||||||
|
]);
|
||||||
|
expect(sources).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('operations calendar windows', () => {
|
describe('operations calendar windows', () => {
|
||||||
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
||||||
|
|||||||
@@ -106,6 +106,24 @@ export function uncoveredOperationIntervals(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmedOperationRanges(
|
||||||
|
targets: OperationInterval[],
|
||||||
|
coverage: OperationInterval[],
|
||||||
|
): Omit<OperationInterval, 'instrumentUid'>[] {
|
||||||
|
return targets.flatMap((target) => coverage
|
||||||
|
.filter((row) =>
|
||||||
|
row.accountId === target.accountId &&
|
||||||
|
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
|
||||||
|
row.from < target.to && target.from < row.to,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
accountId: target.accountId,
|
||||||
|
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
||||||
|
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type OperationCoverage = OperationInterval & {
|
export type OperationCoverage = OperationInterval & {
|
||||||
asOf: Date;
|
asOf: Date;
|
||||||
fetchedAt: Date;
|
fetchedAt: Date;
|
||||||
|
|||||||
+43
-46
@@ -392,52 +392,15 @@ export class BrokerageOperationsRepository {
|
|||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: previous.id },
|
||||||
data: {
|
data: {
|
||||||
intent: {
|
intent: extendRunningIntent({
|
||||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
previous: previousIntent,
|
||||||
atomicIntervals: previousIntent?.atomic
|
publicationAtomic,
|
||||||
? [
|
pending,
|
||||||
...new Set([
|
additions,
|
||||||
...(previousIntent.atomicIntervals ??
|
intervals,
|
||||||
previousIntent.intervals),
|
priority,
|
||||||
...additions.map(intervalKey),
|
previousPriority: previous.priority,
|
||||||
]),
|
}),
|
||||||
]
|
|
||||||
: [],
|
|
||||||
screenIntervals: [
|
|
||||||
...new Set([
|
|
||||||
...(previousIntent?.screenIntervals ??
|
|
||||||
(previous.priority === operationsSyncPriority.screen
|
|
||||||
? (previousIntent?.intervals ?? [])
|
|
||||||
: [])),
|
|
||||||
...(priority === operationsSyncPriority.screen
|
|
||||||
? [...pending, ...additions]
|
|
||||||
.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map(intervalKey)
|
|
||||||
: []),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
intervals: (priority < previous.priority
|
|
||||||
? [
|
|
||||||
...pending.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...additions,
|
|
||||||
...pending.filter(
|
|
||||||
(item) =>
|
|
||||||
!intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [...pending, ...additions]
|
|
||||||
).map(intervalKey),
|
|
||||||
},
|
|
||||||
priority: Math.min(priority, previous.priority),
|
priority: Math.min(priority, previous.priority),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -919,6 +882,40 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extendRunningIntent(input: {
|
||||||
|
additions: OperationInterval[];
|
||||||
|
intervals: OperationInterval[];
|
||||||
|
pending: OperationInterval[];
|
||||||
|
previous: OperationsIntent | undefined;
|
||||||
|
previousPriority: number;
|
||||||
|
priority: number;
|
||||||
|
publicationAtomic: boolean;
|
||||||
|
}): OperationsIntent {
|
||||||
|
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||||
|
const overlapsRequested = (item: OperationInterval) =>
|
||||||
|
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||||
|
const retainedScreen = previous?.screenIntervals ??
|
||||||
|
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||||
|
const addedScreen = priority === operationsSyncPriority.screen
|
||||||
|
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||||
|
: [];
|
||||||
|
const ordered = priority < previousPriority
|
||||||
|
? [
|
||||||
|
...pending.filter(overlapsRequested),
|
||||||
|
...additions,
|
||||||
|
...pending.filter((item) => !overlapsRequested(item)),
|
||||||
|
]
|
||||||
|
: [...pending, ...additions];
|
||||||
|
return {
|
||||||
|
atomic: previous?.atomic ?? publicationAtomic,
|
||||||
|
atomicIntervals: previous?.atomic
|
||||||
|
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||||
|
: [],
|
||||||
|
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||||
|
intervals: ordered.map(intervalKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function intervalKey(interval: OperationInterval): string {
|
function intervalKey(interval: OperationInterval): string {
|
||||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||||
if (!(interval.from < interval.to)) {
|
if (!(interval.from < interval.to)) {
|
||||||
|
|||||||
@@ -129,6 +129,43 @@ describe('persistent portfolio projection', () => {
|
|||||||
]);
|
]);
|
||||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||||
});
|
});
|
||||||
|
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
|
||||||
|
{
|
||||||
|
quantity: { units: '1' },
|
||||||
|
currentPrice: { currency: 'USD', units: '3' },
|
||||||
|
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||||
|
},
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [
|
||||||
|
{ currency: 'rub', units: '2' },
|
||||||
|
{ currency: 'RUB', units: '-2' },
|
||||||
|
{ units: '99' },
|
||||||
|
{ currency: 'EUR', units: '1' },
|
||||||
|
] });
|
||||||
|
await service.request('user', 'private', 'a');
|
||||||
|
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
|
||||||
|
await immediate();
|
||||||
|
}
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.cashBalances).toEqual([
|
||||||
|
{ currency: 'RUB', value: '0' },
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
{ currency: 'USD', value: '3' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.positions[0].currentValue).toBeNull();
|
||||||
|
expect(snapshot.positions[1].absoluteReturn).toBeNull();
|
||||||
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
|
|||||||
@@ -5,18 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
|
||||||
isZero,
|
|
||||||
multiply,
|
|
||||||
subtract,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
@@ -24,9 +14,9 @@ import {
|
|||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankInstrument,
|
|
||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
TbankMoney,
|
type TbankMoney,
|
||||||
|
type TbankPortfolio,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { CandleService } from '../../instruments/instruments.api';
|
import { CandleService } from '../../instruments/instruments.api';
|
||||||
@@ -38,25 +28,12 @@ import {
|
|||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
import {
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
PortfolioDto,
|
|
||||||
PortfolioPositionDto,
|
|
||||||
} from '../presentation/portfolio.dto';
|
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
|
||||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
const types: Record<string, string> = {
|
|
||||||
share: 'STOCK',
|
|
||||||
bond: 'BOND',
|
|
||||||
etf: 'FUND',
|
|
||||||
currency: 'CURRENCY',
|
|
||||||
futures: 'FUTURE',
|
|
||||||
option: 'OPTION',
|
|
||||||
sp: 'STRUCTURED_NOTE',
|
|
||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
@@ -423,109 +400,11 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: {
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
averagePositionPrice?: TbankMoney;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
figi?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
quantity?: { nano?: number | string; units?: number | string };
|
|
||||||
}[],
|
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
): Promise<PortfolioDto> {
|
): Promise<PortfolioDto> {
|
||||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||||
const positions = upstream.map((position) =>
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
||||||
this.position(
|
|
||||||
position,
|
|
||||||
savedMetadata.get(metadataKey(position))?.data ?? null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
|
||||||
for (const value of money) {
|
|
||||||
const currency = value.currency?.trim().toUpperCase();
|
|
||||||
const amount = decimal(value);
|
|
||||||
if (!currency || isZero(amount)) continue;
|
|
||||||
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
|
|
||||||
}
|
|
||||||
const cashBalances = [...cash].map(([currency, value]) => ({
|
|
||||||
currency,
|
|
||||||
value: formatDecimal(value),
|
|
||||||
}));
|
|
||||||
const normalizedPositions = positions.filter(
|
|
||||||
(position): position is PortfolioPositionDto => position !== null,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
lastSuccessfulAt,
|
|
||||||
isStale: false,
|
|
||||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
|
||||||
positions: normalizedPositions,
|
|
||||||
cashBalances,
|
|
||||||
structure: portfolioStructure(normalizedPositions, cashBalances),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private position(
|
|
||||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
|
||||||
metadata: null | TbankInstrument,
|
|
||||||
): null | PortfolioPositionDto {
|
|
||||||
const quantity = decimal(position.quantity ?? {});
|
|
||||||
if (isZero(quantity)) return null;
|
|
||||||
const figi = position.figi?.trim() || null;
|
|
||||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
|
||||||
const currentPrice = this.money(position.currentPrice);
|
|
||||||
const averagePurchasePrice = this.money(position.averagePositionPrice);
|
|
||||||
const currentValue = currentPrice && this.value(quantity, currentPrice);
|
|
||||||
const absoluteReturn =
|
|
||||||
currentPrice &&
|
|
||||||
averagePurchasePrice &&
|
|
||||||
currentPrice.currency === averagePurchasePrice.currency
|
|
||||||
? {
|
|
||||||
currency: currentPrice.currency,
|
|
||||||
value: formatDecimal(
|
|
||||||
multiply(
|
|
||||||
subtract(
|
|
||||||
decimalFromString(currentPrice.value),
|
|
||||||
decimalFromString(averagePurchasePrice.value),
|
|
||||||
),
|
|
||||||
quantity,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
figi,
|
|
||||||
instrumentUid,
|
|
||||||
displayName:
|
|
||||||
metadata?.name?.trim() ||
|
|
||||||
metadata?.ticker?.trim() ||
|
|
||||||
figi ||
|
|
||||||
'Unknown instrument',
|
|
||||||
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
|
||||||
quantity: formatDecimal(quantity),
|
|
||||||
currentValue,
|
|
||||||
averagePurchasePrice,
|
|
||||||
absoluteReturn,
|
|
||||||
isin: metadata?.isin?.trim() || null,
|
|
||||||
ticker: metadata?.ticker?.trim() || null,
|
|
||||||
logoName: metadata?.brand?.logoName?.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private value(
|
|
||||||
quantity: ReturnType<typeof decimal>,
|
|
||||||
price: MoneyDto,
|
|
||||||
): MoneyDto {
|
|
||||||
return {
|
|
||||||
currency: price.currency,
|
|
||||||
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
|
||||||
const currency = value?.currency?.trim().toUpperCase();
|
|
||||||
return currency && value
|
|
||||||
? { currency, value: formatDecimal(decimal(value)) }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,110 @@
|
|||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
|
subtract,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
|
metadataKey,
|
||||||
|
type TbankInstrument,
|
||||||
|
type TbankMoney,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
TbankPositions,
|
TbankPositions,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
|
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
share: 'STOCK',
|
||||||
|
bond: 'BOND',
|
||||||
|
etf: 'FUND',
|
||||||
|
currency: 'CURRENCY',
|
||||||
|
futures: 'FUTURE',
|
||||||
|
option: 'OPTION',
|
||||||
|
sp: 'STRUCTURED_NOTE',
|
||||||
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPortfolio(
|
||||||
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
|
cash: TbankMoney[],
|
||||||
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
||||||
|
lastSuccessfulAt: string,
|
||||||
|
): PortfolioDto {
|
||||||
|
const positions = upstream
|
||||||
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
|
||||||
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
||||||
|
const balances = new Map<string, ReturnType<typeof decimal>>();
|
||||||
|
for (const value of cash) {
|
||||||
|
const currency = value.currency?.trim().toUpperCase();
|
||||||
|
const amount = decimal(value);
|
||||||
|
if (!currency || isZero(amount)) continue;
|
||||||
|
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
||||||
|
}
|
||||||
|
const cashBalances = [...balances].map(([currency, value]) => ({
|
||||||
|
currency,
|
||||||
|
value: formatDecimal(value),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
lastSuccessfulAt,
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: totalsByCurrency(upstream, cash),
|
||||||
|
positions,
|
||||||
|
cashBalances,
|
||||||
|
structure: portfolioStructure(positions, cashBalances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPosition(
|
||||||
|
position: NonNullable<TbankPortfolio['positions']>[number],
|
||||||
|
metadata: null | TbankInstrument,
|
||||||
|
): null | PortfolioPositionDto {
|
||||||
|
const quantity = decimal(position.quantity ?? {});
|
||||||
|
if (isZero(quantity)) return null;
|
||||||
|
const figi = position.figi?.trim() || null;
|
||||||
|
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||||
|
const currentPrice = money(position.currentPrice);
|
||||||
|
const averagePurchasePrice = money(position.averagePositionPrice);
|
||||||
|
const currentValue = currentPrice && {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
||||||
|
};
|
||||||
|
const absoluteReturn =
|
||||||
|
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
||||||
|
? {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(
|
||||||
|
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
||||||
|
quantity,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
figi,
|
||||||
|
instrumentUid,
|
||||||
|
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
|
||||||
|
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
||||||
|
quantity: formatDecimal(quantity),
|
||||||
|
currentValue,
|
||||||
|
averagePurchasePrice,
|
||||||
|
absoluteReturn,
|
||||||
|
isin: metadata?.isin?.trim() || null,
|
||||||
|
ticker: metadata?.ticker?.trim() || null,
|
||||||
|
logoName: metadata?.brand?.logoName?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: TbankMoney | undefined): Money | null {
|
||||||
|
const currency = value?.currency?.trim().toUpperCase();
|
||||||
|
return currency && value
|
||||||
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function totalsByCurrency(
|
function totalsByCurrency(
|
||||||
positions: NonNullable<TbankPortfolio['positions']>,
|
positions: NonNullable<TbankPortfolio['positions']>,
|
||||||
@@ -44,4 +138,4 @@ function totalsByCurrency(
|
|||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { totalsByCurrency };
|
export { mapPortfolio, totalsByCurrency };
|
||||||
|
|||||||
@@ -188,6 +188,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
|
|||||||
).toBe(0);
|
).toBe(0);
|
||||||
expect((await summary('2026-08', card.id)).net.value).toBe('0');
|
expect((await summary('2026-08', card.id)).net.value).toBe('0');
|
||||||
});
|
});
|
||||||
|
it('retains omitted metadata on a name-only patch and clears explicit nulls', async () => {
|
||||||
|
const card = await createCard('0', owner, { bankName: 'Bank', lastFour: '1234' });
|
||||||
|
const renamed = (await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: card.version, name: 'Renamed' }).expect(200)).body as BankCardDto;
|
||||||
|
expect(renamed).toMatchObject({ name: 'Renamed', bankName: 'Bank', lastFour: '1234' });
|
||||||
|
const cleared = (await auth('patch', '/' + card.id)
|
||||||
|
.send({ version: renamed.version, bankName: null, lastFour: null }).expect(200)).body as BankCardDto;
|
||||||
|
expect(cleared).toMatchObject({ name: 'Renamed', bankName: null, lastFour: null });
|
||||||
|
expect(cleared.version).toBe(renamed.version + 1);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
|
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
|
||||||
const card = await createCard();
|
const card = await createCard();
|
||||||
const expense = await createOp(op(card.id));
|
const expense = await createOp(op(card.id));
|
||||||
|
|||||||
@@ -91,6 +91,36 @@ test('numeric literals require names, including previously ignored numbers and b
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('architecture imports enforce direct boundaries without banning DTOs or pure protocol inputs', async () => {
|
||||||
|
const domain = 'src/modules/banking/bank-cards/domain/rub-money.ts';
|
||||||
|
const controller = 'src/modules/auth/presentation/auth.controller.ts';
|
||||||
|
const useCase = 'src/modules/banking/bank-cards/application/get-bank-summary.use-case.ts';
|
||||||
|
for (const [filePath, code] of [
|
||||||
|
[domain, 'import { Injectable } from "@nestjs/common";'],
|
||||||
|
[domain, 'import { MoneyDto } from "../../../../common/money.dto";'],
|
||||||
|
[domain, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
||||||
|
[controller, 'import { PrismaService } from "../../../prisma/prisma.service";'],
|
||||||
|
[controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'],
|
||||||
|
[useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-imports'), code);
|
||||||
|
}
|
||||||
|
for (const [filePath, code] of [
|
||||||
|
[domain, 'import type { Money } from "../../../../common/money.types";'],
|
||||||
|
[domain, 'import { TbankOperationState } from "../../../../integrations/tbank/tbank-protocol";'],
|
||||||
|
[controller, 'import { AuthService } from "../application/auth.service";'],
|
||||||
|
[useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'],
|
||||||
|
[useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'],
|
||||||
|
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0);
|
||||||
|
assert.ok(result.messages.every(({ ruleId }) => ruleId !== 'no-restricted-imports'), code);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
|
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
|
||||||
test('machine codes require enum members without banning ordinary text or Prisma selects', async () => {
|
test('machine codes require enum members without banning ordinary text or Prisma selects', async () => {
|
||||||
const eslint = new ESLint();
|
const eslint = new ESLint();
|
||||||
|
|||||||
@@ -115,6 +115,40 @@ describe('Request-driven operations synchronization (SQLite)', () => {
|
|||||||
});
|
});
|
||||||
afterAll(async () => prisma.$disconnect());
|
afterAll(async () => prisma.$disconnect());
|
||||||
|
|
||||||
|
it.each([[false, 1], [true, 1], [false, 2], [true, 2]] as const)(
|
||||||
|
'extends legacy intents in stable priority order (atomic=%s, previousPriority=%s)',
|
||||||
|
async (atomic, previousPriority) => {
|
||||||
|
const run = 'legacy-run';
|
||||||
|
const initial = await repository.request('owner', 'revision', [january, february], run, false, new Set(), previousPriority);
|
||||||
|
if (!initial) throw new Error('Missing work');
|
||||||
|
const key = (range: typeof january) => JSON.stringify([
|
||||||
|
range.accountId, range.from.toISOString(), range.to.toISOString(), '',
|
||||||
|
]);
|
||||||
|
const march = { ...january, from: february.to, to: new Date('2026-04-01Z') };
|
||||||
|
await prisma.tbankSyncWork.update({
|
||||||
|
where: { id: initial.work.id },
|
||||||
|
data: { intent: { atomic, intervals: [key(february), key(january)] } },
|
||||||
|
});
|
||||||
|
const extended = await repository.request(
|
||||||
|
'owner', 'revision', [january, march], run, false,
|
||||||
|
new Set([initial.work.attemptId]), 1,
|
||||||
|
);
|
||||||
|
expect(extended?.launch).toBe(false);
|
||||||
|
expect(extended?.work.id).toBe(initial.work.id);
|
||||||
|
expect(extended?.work.priority).toBe(1);
|
||||||
|
expect(extended?.work.intent).toEqual({
|
||||||
|
atomic,
|
||||||
|
atomicIntervals: atomic ? [key(february), key(january), key(march)] : [],
|
||||||
|
screenIntervals: previousPriority === 1
|
||||||
|
? [key(february), key(january), key(march)]
|
||||||
|
: [key(january), key(march)],
|
||||||
|
intervals: previousPriority === 1
|
||||||
|
? [key(february), key(january), key(march)]
|
||||||
|
: [key(january), key(march), key(february)],
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
it('joins overlapping account and instrument refresh into one atomic publication group', async () => {
|
it('joins overlapping account and instrument refresh into one atomic publication group', async () => {
|
||||||
const gate = deferred<TbankOperationsByCursorResponse>();
|
const gate = deferred<TbankOperationsByCursorResponse>();
|
||||||
upstream.getOperationsByCursor
|
upstream.getOperationsByCursor
|
||||||
|
|||||||
Reference in New Issue
Block a user