13 Commits
Author SHA1 Message Date
ksv741 14a204de0d chore: guard backend responsibility boundaries
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 10m0s
2026-10-01 20:53:01 +03:00
ksv741 a13cf84ead refactor: isolate operation ranges and intents 2026-10-01 20:53:01 +03:00
ksv741 7fe88a12c7 refactor: centralize provider projections 2026-10-01 20:53:00 +03:00
ksv741 190eb90ffe refactor: clarify nullable card metadata 2026-10-01 20:53:00 +03:00
ksv741 fb2e851e7e refactor: share authentication result type 2026-10-01 20:53:00 +03:00
ksv741 15a3d5f331 fix(deps): resolve npm audit advisories
Patch gRPC and the existing multer override.
Refresh vulnerable transitive versions.
Scope patched undici/basic-ftp to release-it; retain the audit gate.
2026-10-01 20:51:32 +03:00
ksv741 bee34290fb chore: synchronize develop before audit fixes 2026-10-01 20:08:37 +03:00
ksv741 6cd9c2dd28 perf: group catalog source reads by identity
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 11m54s
2026-09-28 16:37:01 +03:00
ksv741 b24433092b fix: resolve build imports before linting
Resolve dist imports in runtime scripts against their TypeScript sources.

Keep missing-module checks enabled and cover absent build output in the lint gate.

Verified npm ci, prisma generate and full verify with dist absent at startup.
2026-09-28 16:37:01 +03:00
ksv741 33592c1d5f refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 16:37:01 +03:00
ksv741 183b7f1efd style: align multiline ternary operators 2026-09-28 16:37:01 +03:00
ksv741 b78c215ff1 refactor: replace ignored numeric literals with constants 2026-09-28 16:37:01 +03:00
ksv741 a970eaaf7a chore: strengthen backend eslint profile 2026-09-28 16:37:01 +03:00
23 changed files with 470 additions and 466 deletions
+5 -2
View File
@@ -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.
+4
View File
@@ -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`
+49
View File
@@ -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',
+45 -214
View File
@@ -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
View File
@@ -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"
}
+6 -6
View File
@@ -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;
@@ -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 };
+11
View File
@@ -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));
+30
View File
@@ -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();
+34
View File
@@ -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