diff --git a/.ai/architecture.md b/.ai/architecture.md index 2dc1b5f..8776372 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -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. diff --git a/README.md b/README.md index 4ab2683..7104c04 100644 --- a/README.md +++ b/README.md @@ -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` diff --git a/eslint.config.mjs b/eslint.config.mjs index 998a57b..9fde38f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/package-lock.json b/package-lock.json index 697eecf..a37b5d4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/package.json b/package.json index 9ef6d3c..e98d7d7 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index 8410ab6..6b0a2fa 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -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 { 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 { 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 { let payload: RefreshPayload; try { payload = await this.jwt.verifyAsync(token, { @@ -151,7 +151,7 @@ export class AuthService { private async createSession( user: User, - ): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { + ): Promise { 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 { const refreshToken = await this.jwt.signAsync( { sub: session.userId, diff --git a/src/modules/auth/application/auth.types.ts b/src/modules/auth/application/auth.types.ts index 6e5348e..333e4e9 100644 --- a/src/modules/auth/application/auth.types.ts +++ b/src/modules/auth/application/auth.types.ts @@ -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; diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index 8f78326..a121898 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -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 }; diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index 7a777f9..e1e6d74 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -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 diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 1c8f778..200d416 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -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; } diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index cfc78a0..6cf0824 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -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'); } diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts new file mode 100644 index 0000000..52d7d59 --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.spec.ts @@ -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'); + }); +}); diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts new file mode 100644 index 0000000..08942ea --- /dev/null +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts @@ -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 }; diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index 800dc29..9108459 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -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}`, diff --git a/src/modules/investments/operations/domain/operations-sync-plan.spec.ts b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts index f2c46f0..00b4068 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.spec.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.spec.ts @@ -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', () => { diff --git a/src/modules/investments/operations/domain/operations-sync-plan.ts b/src/modules/investments/operations/domain/operations-sync-plan.ts index 6477330..8ae4061 100644 --- a/src/modules/investments/operations/domain/operations-sync-plan.ts +++ b/src/modules/investments/operations/domain/operations-sync-plan.ts @@ -106,6 +106,24 @@ export function uncoveredOperationIntervals( }); } +export function confirmedOperationRanges( + targets: OperationInterval[], + coverage: OperationInterval[], +): Omit[] { + 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; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 7181714..844d1a8 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -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)) { diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 5df4ec9..f29cf3a 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -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(), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index 8da8363..14d7f19 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -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 = { - 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, money: TbankMoney[], ): Promise { 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>(); - 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[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, - 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); } } diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index f4ae5b6..08c8873 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -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 = { + share: 'STOCK', + bond: 'BOND', + etf: 'FUND', + currency: 'CURRENCY', + futures: 'FUTURE', + option: 'OPTION', + sp: 'STRUCTURED_NOTE', + dfa: 'DIGITAL_FINANCIAL_ASSET', +}; + +function mapPortfolio( + upstream: NonNullable, + cash: TbankMoney[], + metadata: ReadonlyMap, + 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>(); + 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[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, @@ -44,4 +138,4 @@ function totalsByCurrency( .map(([currency, value]) => ({ currency, value: formatDecimal(value) })); } -export { totalsByCurrency }; +export { mapPortfolio, totalsByCurrency }; diff --git a/test/bank-cards.e2e-spec.ts b/test/bank-cards.e2e-spec.ts index cfa6de5..97effb8 100644 --- a/test/bank-cards.e2e-spec.ts +++ b/test/bank-cards.e2e-spec.ts @@ -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)); diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 592f8c0..4a8b9d1 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -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(); diff --git a/test/tbank-operations-sync.e2e-spec.ts b/test/tbank-operations-sync.e2e-spec.ts index 0937213..7ad1719 100644 --- a/test/tbank-operations-sync.e2e-spec.ts +++ b/test/tbank-operations-sync.e2e-spec.ts @@ -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(); upstream.getOperationsByCursor