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.
|
||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
||||
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||
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`; не пересоздавайте его
|
||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||
|
||||
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||
|
||||
## SonarQube
|
||||
|
||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||
|
||||
@@ -15,6 +15,9 @@ import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||
const prismaImports = [
|
||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||
@@ -119,6 +122,52 @@ export default defineConfig(
|
||||
'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: [
|
||||
'src/**/*.ts',
|
||||
|
||||
Generated
+45
-214
@@ -9,7 +9,7 @@
|
||||
"version": "0.2.3",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.4",
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
"@grpc/proto-loader": "0.8.1",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
@@ -1425,29 +1425,6 @@
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -1536,9 +1513,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@grpc/grpc-js": {
|
||||
"version": "1.14.4",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
||||
"version": "1.14.5",
|
||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
|
||||
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@grpc/proto-loader": "^0.8.0",
|
||||
@@ -2797,29 +2774,6 @@
|
||||
"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": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||
@@ -5095,29 +5049,6 @@
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||
@@ -6304,9 +6245,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/basic-ftp": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -6415,6 +6356,19 @@
|
||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||
"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": {
|
||||
"version": "4.28.8",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||
@@ -6506,6 +6460,7 @@
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/builtin-modules": {
|
||||
@@ -7019,6 +6974,7 @@
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"dev": true,
|
||||
"engines": [
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -8658,29 +8568,6 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||
@@ -9053,9 +8940,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/fast-uri": {
|
||||
"version": "3.1.7",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -9537,29 +9424,6 @@
|
||||
"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": {
|
||||
"version": "10.2.6",
|
||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||
@@ -9967,9 +9831,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.7.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
||||
"version": "10.7.2",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -11641,14 +11505,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
||||
"version": "2.4.0",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"type-is": "^1.6.18"
|
||||
},
|
||||
"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": {
|
||||
"version": "2.33.4",
|
||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||
@@ -14181,29 +14034,6 @@
|
||||
"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": {
|
||||
"version": "10.5.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||
@@ -14234,9 +14064,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"version": "2.1.7",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -14633,6 +14463,7 @@
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
|
||||
+6
-2
@@ -37,7 +37,7 @@
|
||||
"test:lint": "node --test test/eslint-config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.4",
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
"@grpc/proto-loader": "0.8.1",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
@@ -105,11 +105,15 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@nestjs/platform-express": {
|
||||
"multer": "2.3.0"
|
||||
"multer": "2.4.0"
|
||||
},
|
||||
"@prisma/config@7.10.0": {
|
||||
"deepmerge-ts": "8.0.2"
|
||||
},
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.1",
|
||||
"undici": "7.29.1"
|
||||
},
|
||||
"prisma": {
|
||||
"mysql2": "3.24.4"
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
@@ -35,7 +35,7 @@ export class AuthService {
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
@@ -64,7 +64,7 @@ export class AuthService {
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
@@ -80,7 +80,7 @@ export class AuthService {
|
||||
|
||||
async refresh(
|
||||
token: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
@@ -151,7 +151,7 @@ export class AuthService {
|
||||
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
@@ -166,7 +166,7 @@ export class AuthService {
|
||||
private async rotateSession(
|
||||
session: Session & { user: User },
|
||||
expectedRefreshTokenHash?: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: session.userId,
|
||||
|
||||
@@ -3,6 +3,12 @@ export interface AuthUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AuthenticationResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export interface AccessPayload {
|
||||
sid: string;
|
||||
sub: string;
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
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 { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
@@ -117,11 +117,7 @@ export class AuthController {
|
||||
|
||||
private respond(
|
||||
response: Response,
|
||||
result: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { email: string; id: string };
|
||||
},
|
||||
result: AuthenticationResult,
|
||||
) {
|
||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
|
||||
@@ -103,13 +103,9 @@ export class BankCardsService {
|
||||
id,
|
||||
name: dto.name ?? previous.name,
|
||||
bankName:
|
||||
dto.bankName === undefined
|
||||
? (previous.bankName ?? undefined)
|
||||
: (dto.bankName ?? undefined),
|
||||
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||
lastFour:
|
||||
dto.lastFour === undefined
|
||||
? (previous.lastFour ?? undefined)
|
||||
: (dto.lastFour ?? undefined),
|
||||
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||
paymentSystem:
|
||||
dto.paymentSystem === undefined
|
||||
? previous.paymentSystem
|
||||
|
||||
@@ -5,10 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -26,6 +24,7 @@ import {
|
||||
} from '../domain/candle-coverage';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
@@ -413,17 +412,7 @@ export class CandleService {
|
||||
window.from,
|
||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||
);
|
||||
const rows = candles.flatMap((candle) => {
|
||||
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,
|
||||
},
|
||||
];
|
||||
});
|
||||
const rows = candles.map(toSavedCandle);
|
||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
@@ -13,7 +12,6 @@ import {
|
||||
} from '../../../../common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
@@ -37,6 +35,7 @@ import {
|
||||
catalogScope,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
|
||||
@@ -353,17 +352,7 @@ export class CatalogEnrichmentService {
|
||||
gap.from,
|
||||
gap.to,
|
||||
);
|
||||
const rows = response.map((candle) => {
|
||||
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,
|
||||
};
|
||||
});
|
||||
const rows = response.map(toSavedCandle);
|
||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||
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';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import {
|
||||
confirmedOperationRanges,
|
||||
operationIntervalsOverlap,
|
||||
operationsSyncPriority,
|
||||
planOperationRead,
|
||||
@@ -196,22 +197,7 @@ export class BrokerageOperationsService {
|
||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||
movingEnd,
|
||||
});
|
||||
const confirmed = plan.intervals.flatMap((target) =>
|
||||
(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 confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
||||
const missing = uncoveredOperationIntervals(
|
||||
plan.intervals,
|
||||
confirmed.map((range) => ({
|
||||
@@ -544,19 +530,7 @@ export class BrokerageOperationsService {
|
||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||
(complete ? saved?.connection.membershipAt : null) ??
|
||||
null;
|
||||
const confirmed = plan.intervals.flatMap((target) =>
|
||||
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 confirmed = confirmedOperationRanges(plan.intervals, sources);
|
||||
const meta = investmentResourceMeta({
|
||||
// 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}`,
|
||||
|
||||
@@ -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', () => {
|
||||
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 & {
|
||||
asOf: Date;
|
||||
fetchedAt: Date;
|
||||
|
||||
+43
-46
@@ -392,52 +392,15 @@ export class BrokerageOperationsRepository {
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
data: {
|
||||
intent: {
|
||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
||||
atomicIntervals: previousIntent?.atomic
|
||||
? [
|
||||
...new Set([
|
||||
...(previousIntent.atomicIntervals ??
|
||||
previousIntent.intervals),
|
||||
...additions.map(intervalKey),
|
||||
]),
|
||||
]
|
||||
: [],
|
||||
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),
|
||||
},
|
||||
intent: extendRunningIntent({
|
||||
previous: previousIntent,
|
||||
publicationAtomic,
|
||||
pending,
|
||||
additions,
|
||||
intervals,
|
||||
priority,
|
||||
previousPriority: 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 {
|
||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||
if (!(interval.from < interval.to)) {
|
||||
|
||||
@@ -129,6 +129,43 @@ describe('persistent portfolio projection', () => {
|
||||
]);
|
||||
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 () => {
|
||||
const snapshot: PortfolioDto = {
|
||||
lastSuccessfulAt: membershipAt.toISOString(),
|
||||
|
||||
@@ -5,18 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../common/decimal';
|
||||
import { RequestContextService } from '../../../../common/logging/request-context.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 {
|
||||
InstrumentMetadataService,
|
||||
@@ -24,9 +14,9 @@ import {
|
||||
metadataKey,
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankInstrument,
|
||||
TbankMembershipService,
|
||||
TbankMoney,
|
||||
type TbankMoney,
|
||||
type TbankPortfolio,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { CandleService } from '../../instruments/instruments.api';
|
||||
@@ -38,25 +28,12 @@ import {
|
||||
PortfolioRepository,
|
||||
portfolioWorkKey,
|
||||
} from '../infrastructure/portfolio.repository';
|
||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
} from '../presentation/portfolio.dto';
|
||||
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
|
||||
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()
|
||||
export class PortfolioService {
|
||||
@@ -423,109 +400,11 @@ export class PortfolioService {
|
||||
}
|
||||
|
||||
private async normalize(
|
||||
upstream: {
|
||||
averagePositionPrice?: TbankMoney;
|
||||
currentPrice?: TbankMoney;
|
||||
figi?: string;
|
||||
instrumentType?: string;
|
||||
instrumentUid?: string;
|
||||
quantity?: { nano?: number | string; units?: number | string };
|
||||
}[],
|
||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||
money: TbankMoney[],
|
||||
): Promise<PortfolioDto> {
|
||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||
const positions = upstream.map((position) =>
|
||||
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;
|
||||
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,110 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
metadataKey,
|
||||
type TbankInstrument,
|
||||
type TbankMoney,
|
||||
TbankPortfolio,
|
||||
TbankPositions,
|
||||
} 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(
|
||||
positions: NonNullable<TbankPortfolio['positions']>,
|
||||
@@ -44,4 +138,4 @@ function totalsByCurrency(
|
||||
.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);
|
||||
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 () => {
|
||||
const card = await createCard();
|
||||
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.
|
||||
test('machine codes require enum members without banning ordinary text or Prisma selects', async () => {
|
||||
const eslint = new ESLint();
|
||||
|
||||
@@ -115,6 +115,40 @@ describe('Request-driven operations synchronization (SQLite)', () => {
|
||||
});
|
||||
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 () => {
|
||||
const gate = deferred<TbankOperationsByCursorResponse>();
|
||||
upstream.getOperationsByCursor
|
||||
|
||||
Reference in New Issue
Block a user