24 Commits
Author SHA1 Message Date
ksv741 03e75f8d2b chore(release): 2.0.0
Backend image / publish (push) Successful in 9m4s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m16s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Successful in 6m13s
2026-10-07 18:14:23 +03:00
ksv741 dbb1748a5f chore: synchronize main history before release 2026-10-07 18:13:12 +03:00
ksv741 01e2de6a04 test: separate performance budget from e2e gate
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 39s
Backend verify / verify (pull_request) Successful in 5m46s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 16s
Backend verify / verify (push) Successful in 6m50s
2026-10-07 16:35:23 +03:00
ksv741 30e96e51e2 fix(deps): restore backend security gate
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 10m22s
2026-10-07 15:19:42 +03:00
ksv741 a7619d5c8f perf(income): compute Moscow dates once
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 10m3s
2026-10-03 19:44:39 +03:00
ksv741 d698ed9425 refactor!: simplify runtime coordination #7
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
Keep financial ownership, atomic publication and refresh CAS.
Failed refresh leaves cookies untouched; logout still clears them.

Remove full-history candle prefetch and special metadata retries.
Use native Pino request context instead of parallel middleware.

BREAKING CHANGE: remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
2026-10-03 11:38:08 +03:00
ksv741 51f345bdc5 Merge branch 'develop' into main
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m46s
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Failing after 10s
Backend verify / verify (pull_request) Skipped
2026-10-02 08:22:31 +03:00
ksv741 0f27da9fd1 Merge main into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m48s
2026-10-02 08:21:02 +03:00
codex-bot 1be6db65d7 refactor: simplify investment data flow
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 7s
Backend verify / verify (push) Failing after 1m43s
2026-10-02 08:12:02 +03:00
codex-bot 99de4af678 Merge pull request #42 from codex/backend-eslint
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Canceled after 3m5s
2026-10-02 08:05:04 +03:00
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
ksv741 92f216d520 chore(release): 1.0.0
Backend security / security (push) Successful in 8s
Backend image / publish (push) Successful in 12m35s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / verify (push) Successful in 9m56s
2026-09-26 22:33:48 +03:00
73 changed files with 884 additions and 1478 deletions
+7 -4
View File
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
request context и ограничивает поля событий. Jest unit tests, изолированные
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.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.
+4
View File
@@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе.
+3 -1
View File
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
+3 -1
View File
@@ -60,7 +60,9 @@ jobs:
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high
verify:
needs: [commits, security]
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
### Bug Fixes
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
### Performance
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
### Refactoring
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
### CI
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
### Tests
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
### Styles
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
### Maintenance
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES
* investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
### Features
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
### Tests
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
### Bug Fixes
+16 -5
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
выполните обе команды.
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -81,12 +84,16 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и `npm run test:e2e`. Используется только checkout
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low`.
значениями, блокирует любой runtime advisory через
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются.
@@ -150,6 +157,10 @@ lint. Он не защищает от усложнения уже учтённо
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
Рост baseline — изменение quality gate: он требует отдельного обоснования
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
+3 -8
View File
@@ -4,11 +4,6 @@
"count": 1
}
},
"src/common/logging/request-logging.middleware.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
@@ -54,7 +49,7 @@
},
"src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 4
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 4
@@ -169,7 +164,7 @@
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
@@ -203,4 +198,4 @@
"count": 3
}
}
}
}
+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',
+61 -226
View File
@@ -1,15 +1,15 @@
{
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"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",
@@ -2186,6 +2163,13 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -2797,29 +2781,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 +5056,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 +6208,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 +6252,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 +6363,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 +6467,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 +6981,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 +8321,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 +8527,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 +8575,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 +8947,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 +9431,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 +9838,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 +11512,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": {
@@ -12829,9 +12699,9 @@
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -12839,6 +12709,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-agent": {
@@ -13213,16 +13087,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",
@@ -13803,13 +13667,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sql-escaper": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
@@ -14181,29 +14038,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 +14068,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 +14467,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": {
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"description": "",
"author": "",
"private": true,
@@ -26,6 +26,7 @@
"test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs",
@@ -37,7 +38,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",
@@ -104,12 +105,17 @@
"typescript-eslint": "8.70.1"
},
"overrides": {
"proxy-addr": "2.0.8",
"@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"
}
-1
View File
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
+2 -36
View File
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { ConfigModule } from '@nestjs/config';
import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation';
import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true,
validate: validateEnv,
}),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule,
PrismaModule,
AuthModule,
+59 -9
View File
@@ -1,16 +1,66 @@
import { Global, Module } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { Options } from 'pino-http';
import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware';
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { Environment } from '../../config/env.validation';
import { StructuredLogger } from './structured-logger.service';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global()
@Module({
providers: [
RequestContextService,
RequestLoggingMiddleware,
StructuredLogger,
],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
imports: [LoggerModule.forRootAsync<Request, Response>({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
})],
providers: [StructuredLogger],
exports: [StructuredLogger],
})
export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
};
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
run<T>(context: RequestLogContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestLogContext | undefined {
return this.storage.getStore();
}
set(context: Partial<RequestLogContext>): void {
const current = this.storage.getStore();
if (current) Object.assign(current, context);
}
}
@@ -1,54 +0,0 @@
import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => {
it('returns a server ID and emits one safe completion event', () => {
const requestContext = {
run: jest.fn((_: unknown, next: () => void) => next()),
};
const logger = { event: jest.fn() };
const middleware = new RequestLoggingMiddleware(
requestContext as never,
logger as never,
);
const response = Object.assign(new EventEmitter(), {
statusCode: 200,
locals: {},
setHeader: jest.fn(),
});
const next = jest.fn();
const request: {
baseUrl: string;
header: jest.Mock<string>;
id?: string;
method: string;
route: { path: string };
} = {
method: 'GET',
baseUrl: '/api/v1',
route: { path: '/accounts/:id' },
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
};
middleware.use(request as never, response as never, next);
response.emit('finish');
expect(response.setHeader).toHaveBeenCalledWith(
'X-Request-ID',
expect.stringMatching(/^[0-9a-f-]{36}$/i),
);
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(requestContext.run).toHaveBeenCalledWith(
expect.objectContaining({
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
}),
next,
);
expect(logger.event).toHaveBeenCalledWith(
'info',
'http.request.completed',
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
);
});
});
@@ -1,67 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
constructor(
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
use(request: Request, response: Response, next: NextFunction): void {
const incoming = request.header('x-request-id');
const clientRequestId =
incoming && uuid.test(incoming) ? incoming : undefined;
const existingRequestId = (request as Request & { id?: unknown }).id;
const requestId =
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
(request as unknown as { id: string }).id = requestId;
const startedAt = performance.now();
response.setHeader('X-Request-ID', requestId);
response.on('finish', () => {
const routeRequest: unknown = request;
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string'
? `${request.baseUrl}${routePath}`
: '<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode
? { code: response.locals.publicErrorCode as string }
: {}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = {
debug: jest.fn(),
info: jest.fn(),
@@ -12,8 +11,7 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.info('event', {
userId: 'user',
@@ -23,7 +21,6 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith(
{
requestId: 'request',
userId: 'user',
brokerageAccountId: 'account',
},
@@ -32,8 +29,7 @@ describe('StructuredLogger', () => {
});
it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined);
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.event('debug', 'debug event');
logger.event('warn', 'warn event');
@@ -1,8 +1,6 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>;
@@ -27,10 +25,7 @@ const allowedFields = new Set([
@Injectable()
export class StructuredLogger implements OnApplicationShutdown {
constructor(
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
constructor(private readonly pino: PinoLogger) {}
info(message: string, context?: LogContext): void {
this.write('info', message, context);
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }),
this.safeContext(context),
message,
);
}
+12 -48
View File
@@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
'recovers metadata after one retry for transient gRPC status %s',
async (code) => {
requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999);
await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
},
);
it('rechecks a full queue before admitting a metadata retry', async () => {
it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50;
requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi');
await jest.advanceTimersByTimeAsync(1999);
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(queue.run).not.toHaveBeenCalled();
queue.size = 49;
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
expect(jest.getTimerCount()).toBe(0);
});
it('does not bypass queue admission after an upstream failure', async () => {
@@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50;
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0;
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
]);
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(await result).toBeInstanceOf(ServiceUnavailableException);
expect(jest.getTimerCount()).toBe(0);
});
it('stops retrying an unavailable upstream after three RPCs', async () => {
it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
jest.advanceTimersByTimeAsync(1000),
]);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
});
+9 -27
View File
@@ -46,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000;
@@ -419,12 +417,6 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>(
token,
@@ -486,8 +478,7 @@ export class TbankClient {
request: object,
): Promise<Response> {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
@@ -508,34 +499,25 @@ export class TbankClient {
const failureContext = queueFull
? { code: TbankErrorCode.Unavailable }
: { grpcCode };
const retryable = queueFull
? metadataCall
: [
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
const retryable = !queueFull && [
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
this.logger.event('warn', 'tbank.rpc.retry', {
operation: method,
...failureContext,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
...failureContext,
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
-3
View File
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config';
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
});
app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes(
new ValidationPipe({
forbidUnknownValues: true,
+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';
@@ -85,14 +85,9 @@ export class AuthController {
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return this.respond(response, await this.auth.refresh(token));
}
@Post('logout')
@@ -117,11 +112,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 };
@@ -1,12 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
constructor(private readonly logger: PinoLogger) {
super();
}
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub });
this.logger.assign({ userId: request.user.sub });
}
return Boolean(allowed);
}
@@ -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
@@ -49,7 +49,7 @@ export class BankCardsRepository {
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) {
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
continue;
}
if (busy) conflict(BankErrorCode.WriteConflict);
@@ -1 +0,0 @@
export { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -189,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
).toEqual(['10.25', '-0.25']);
});
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
seed([
{
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
occurredAt: '2026-01-14T20:59:59.999Z',
},
{
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
occurredAt: '2026-01-14T21:00:00.000Z',
},
{
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
occurredAt: '2026-01-15T20:59:59.999Z',
},
]);
const response = await service.analytics(
'user',
'account',
'2026-01-14',
'2026-01-16',
);
expect(response.data?.buckets).toEqual([
{
period: { from: '2026-01-14', to: '2026-01-14' },
totalsByCurrency: [expect.objectContaining({
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-15', to: '2026-01-15' },
totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-16', to: '2026-01-16' },
totalsByCurrency: [],
},
]);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(
@@ -232,13 +232,16 @@ function buckets(
granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[],
) {
const datedEntries = entries.map((entry) => ({
entry,
date: moscowDate(new Date(entry.occurredAt)),
}));
return rangeBuckets(range, granularity).map((period) => ({
period,
totalsByCurrency: totals(
entries.filter((entry) => {
const date = moscowDate(new Date(entry.occurredAt));
return date >= period.from && date <= period.to;
}),
datedEntries
.filter(({ date }) => date >= period.from && date <= period.to)
.map(({ entry }) => entry),
),
}));
}
@@ -1 +0,0 @@
export { IncomeAnalyticsService } from './income-analytics.service';
@@ -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,
@@ -17,7 +15,6 @@ import {
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { planInvestmentScreen } from '../../investment-sync-plan';
import {
candleGaps,
type CandleRange,
@@ -26,11 +23,10 @@ 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';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1;
@@ -56,7 +52,6 @@ type Producer = {
};
const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000;
const prefetchReadBatchSize = 100;
function scope(input: CandleInput) {
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
}
@@ -94,7 +89,6 @@ export class CandleService {
private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve();
private running = false;
private readonly prefetches = new Map<string, Promise<void>>();
constructor(
private readonly repository: CandleRepository,
private readonly sources: CatalogEnrichmentService,
@@ -206,110 +200,6 @@ export class CandleService {
};
}
prefetch(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const key = JSON.stringify([
userId,
revision,
positions
.map((position) => [position.instrumentUid, position.type])
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
.sort(),
]);
const existing = this.prefetches.get(key);
if (existing) return existing;
const work = this.prefetchPositions(userId, revision, positions).finally(
() => this.prefetches.delete(key),
);
this.prefetches.set(key, work);
return work;
}
private async prefetchPositions(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const now = new Date();
const unique = new Map(
positions
.filter(
(position) =>
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
)
.map((position) => [position.instrumentUid ?? '', position]),
);
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
for (const instrumentType of ['share', 'bond'] as const) {
const uids = [...unique]
.filter(
([, position]) =>
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
)
.map(([uid]) => uid);
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += prefetchReadBatchSize
) {
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
details.push(
...(await this.sources.readMany(userId, instrumentType, batch, true, [
'detail',
])),
);
coverage.push(...(await this.repository.coverage(batch)));
}
}
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
for (const [uid, position] of unique) {
const instrumentType: CandleInput['instrumentType'] =
position.type === 'STOCK' ? 'share' : 'bond';
const detail = byUid.get(uid);
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
const plan = planInvestmentScreen(
{ screen: 'account' },
{
membershipKnown: true,
accounts: [],
instruments: [
{
uid,
type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null,
},
],
},
now,
);
if (
!detail?.instrument ||
plan.sources.some((source) => source.kind === 'candles')
) {
const input = { uid, instrumentType, period: 'all' as const };
const range =
detail?.instrument && history
? rangeFor(input, detail.instrument, now)
: null;
if (
!range ||
candleGaps(
range,
coverage.filter((row) => row.instrumentUid === uid),
now,
).length
) {
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
}
}
}
}
request(
userId: string,
revision: string,
@@ -413,17 +303,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;
}
@@ -510,17 +390,6 @@ export class CandleService {
this.reservations = finished.catch(() => undefined);
if (!(await finished)) {
this.queue.push(producer);
continue;
}
if (success && !producer.force && producer.input.period !== 'all') {
await this.request(
producer.attempt.userId,
producer.attempt.revision,
{ ...producer.input, period: 'all' },
producer.now,
false,
BACKGROUND_SYNC_PRIORITY,
);
}
}
} catch {
@@ -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');
}
@@ -12,6 +12,7 @@ import {
TbankAccessService,
type TbankBond,
type TbankCoupon,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
@@ -122,6 +123,10 @@ export class BondIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
@@ -53,12 +53,6 @@ export class CandleRepository {
});
}
coverage(uids: string[]) {
return this.prisma.tbankCandleCoverage.findMany({
where: { provider, instrumentUid: { in: uids }, interval },
});
}
reserve(
owner: { revision: string; userId: string },
scopeKey: string,
@@ -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 };
@@ -1,2 +0,0 @@
export { BondIncomeService } from './bonds/application/bond-income.service';
export { StockIncomeService } from './stocks/application/stock-income.service';
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import {
TbankAccessService,
type TbankDividend,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
@@ -91,6 +92,10 @@ export class StockIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
export const syncStateValues = ['idle', 'running', 'failed'] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
@ApiProperty()
declare dataRevision: string;
@ApiProperty()
declare stateVersion: string;
@ApiPropertyOptional({ type: String })
unavailableReason?: string;
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
const stale = investmentResourceMeta(input);
expect(stale.freshness).toBe('stale');
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
expect(
investmentResourceMeta({
...input,
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
});
expect(terminal.sync).toEqual(failed.sync);
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
expect(
investmentResourceMeta({
...input,
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { InvestmentResourceMetaDto } from './investment-resource.dto';
export function investmentResourceMeta(input: {
@@ -31,7 +29,6 @@ export function investmentResourceMeta(input: {
];
const sync =
states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'queued') ??
states.find((state) => state.state === 'failed') ??
ownSync;
const sourceFreshness = input.fetchedAt
@@ -71,19 +68,5 @@ export function investmentResourceMeta(input: {
...(input.coverage ? { coverage: input.coverage } : {}),
...(input.dependencies ? { dependencies: input.dependencies } : {}),
};
return {
...meta,
stateVersion: createHash('sha256')
.update(
JSON.stringify({
...meta,
// The requested window is identified by resourceKey. Its moving "now"
// boundary is not a change to saved coverage between status and GET.
coverage: meta.coverage
? { ...meta.coverage, requested: undefined }
: undefined,
}),
)
.digest('base64url'),
};
return meta;
}
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
priority: 'screen',
});
}
if (
!force &&
['account', 'events', 'income', 'operations'].includes(screen.screen)
) {
if (instrument.historyFrom) {
sources.push({
...identity,
kind: 'candles',
from: instrument.historyFrom,
to: now,
priority: 'history',
});
} else {
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
}
}
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
sources.push(
@@ -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)) {
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
ForecastRepository,
@@ -10,7 +9,6 @@ import {
} from '../infrastructure/forecast.repository';
const MISSING_DATA_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@Injectable()
export class ForecastService {
@@ -73,23 +71,8 @@ export class ForecastService {
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
const payload = stored ? (stored.payload as T) : null;
const data =
payload &&
'positionIsStale' in payload &&
'positionAt' in payload &&
typeof payload.positionAt === 'string'
? {
...payload,
positionIsStale: tbankSourceNeedsRefresh(
'portfolio',
new Date(payload.positionAt),
new Date(Date.now()),
),
}
: payload;
return {
data,
data: stored ? (stored.payload as T) : null,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
@@ -101,10 +84,7 @@ export class ForecastService {
? 'partial'
: 'missing',
fetchedAt: first ? new Date(first) : null,
freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true
? STALE_FRESHNESS_MS
: Number.POSITIVE_INFINITY,
freshForMs: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null,
dependencies,
}),
@@ -1,4 +1,3 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { setImmediate as immediate } from 'node:timers/promises';
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
service = new PortfolioService(
access as never,
tbank as never,
{ set: jest.fn() } as never,
metadata as never,
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
);
access.local.mockResolvedValue({
revision: 'private',
@@ -129,6 +126,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(),
@@ -184,15 +218,4 @@ describe('persistent portfolio projection', () => {
expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
it('keeps pending data unavailable to internal financial consumers', async () => {
repository.read.mockResolvedValue({
membershipAt: null,
membershipRevision: 0,
accounts: [],
work: [],
});
await expect(service.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
});
@@ -1,22 +1,10 @@
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} 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,12 +12,11 @@ import {
metadataKey,
TbankAccessService,
TbankClient,
type TbankInstrument,
TbankMembershipService,
TbankMoney,
type TbankMoney,
type TbankPortfolio,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
@@ -38,46 +25,29 @@ 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 {
private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
private readonly candles: CandleService,
) {}
async saved(userId: string, accountId: string, observe = false) {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const connection = await this.access.local(userId, accountId);
if (!observe) {
const discovery = await this.membership.request(
@@ -146,28 +116,6 @@ export class PortfolioService {
}
}
}
if (!observe) {
void this.candles
.prefetch(
userId,
connection.revision,
(saved?.accounts ?? [])
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap(
(account) =>
(
account.portfolio?.payload as unknown as
PortfolioDto | undefined
)?.positions ?? [],
),
)
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
}
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -342,7 +290,7 @@ export class PortfolioService {
revision,
accountId,
this.processRunId,
this.active,
this.pending,
force,
);
if (!requested) return null;
@@ -353,7 +301,6 @@ export class PortfolioService {
};
}
const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => {
const attempt = { id, attemptId, userId, revision };
@@ -377,13 +324,6 @@ export class PortfolioService {
for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position);
}
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
}
} catch (error) {
if (
@@ -400,7 +340,6 @@ export class PortfolioService {
}),
)
.finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId);
});
this.pending.set(attemptId, done);
@@ -410,122 +349,12 @@ export class PortfolioService {
return reserved;
}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
const result = await this.read(userId, accountId);
if (!result.data) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
return result.data;
}
getAll(userId: string): Promise<PortfolioDto> {
return this.get(userId, 'all');
}
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);
}
}
@@ -44,15 +44,9 @@ export class ForecastRepository {
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
}
const connection = await tx.tbankConnection.findFirst({
where: {
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
});
if (!connection) return null;
} else if (!(await tx.tbankConnection.findFirst({
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
}))) return null;
const key = { ...owner, scopeKey: input.scopeKey };
const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key },
@@ -52,7 +52,7 @@ export class PortfolioRepository {
revision: string,
accountId: string,
processRunId: string,
active: ReadonlySet<string>,
pending: ReadonlyMap<string, Promise<void>>,
force: boolean,
) {
return this.prisma.$transaction(async (tx) => {
@@ -83,7 +83,7 @@ export class PortfolioRepository {
if (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
pending.has(previous.attemptId)
) {
return { work: previous, launch: false };
}
@@ -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 };
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { ForecastService } from './application/forecast.service';
import { PortfolioService } from './application/portfolio.service';
import { ForecastRepository } from './infrastructure/forecast.repository';
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
import { PortfolioController } from './presentation/portfolio.controller';
@Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
imports: [AuthModule, TbankModule],
controllers: [PortfolioController],
providers: [
PortfolioService,
@@ -22,7 +22,6 @@ import {
} from 'class-validator';
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { operationCategories } from '../operations/operations.api';
const MIN_ACCOUNT_ID_LENGTH = 1;
@@ -216,11 +215,6 @@ export class TbankSyncAcceptedDto {
@ApiProperty({ format: 'date-time' }) declare now: string;
}
export class TbankSyncProgressDto {
@ApiProperty() declare completed: number;
@ApiProperty() declare total: number;
@ApiProperty() declare discovering: boolean;
}
export class TbankSyncStatusDto {
@ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
@@ -232,10 +226,4 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string;
@ApiProperty({ type: TbankSyncProgressDto })
declare progress: TbankSyncProgressDto;
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true })
declare resources: InvestmentResourceMetaDto[];
}
@@ -2,13 +2,9 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
import { IncomeModule } from '../income/income.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { InstrumentsModule } from '../instruments/instruments.module';
import { OperationsModule } from '../operations/operations.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncRepository } from './tbank-sync.repository';
import { TbankSyncService } from './tbank-sync.service';
@@ -19,10 +15,6 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule,
OperationsModule,
InstrumentSourcesModule,
InstrumentsModule,
BrokerageAccountsModule,
IncomeModule,
UpcomingEventsModule,
],
controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository],
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../generated/prisma/client';
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
@@ -27,7 +27,7 @@ export class TbankSyncRepository {
data: { membershipRevision: { increment: 0 } },
});
if (!valid.count) return null;
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`;
const scopeKey = `screen:${JSON.stringify(scope)}`;
const key = { userId, revision, scopeKey };
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: key },
@@ -6,8 +6,6 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import {
@@ -16,26 +14,17 @@ import {
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
import { IncomeAnalyticsService } from '../income/income-read.api';
import {
BondIncomeService,
StockIncomeService,
} from '../instruments/instrument-income.api';
import {
CandleService,
CatalogDiscoveryService,
CatalogEnrichmentService,
} from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { planInvestmentScreen } from '../investment-sync-plan';
import {
BrokerageOperationsService,
BrokerageOperationsSyncService,
type OperationInterval,
} from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
import { normalizeScreen } from './tbank-screen';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import {
@@ -44,9 +33,6 @@ import {
} from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0;
const EXCLUSIVE_END_OFFSET_MS = 1;
const PENDING_CHILD_COUNT = 0;
const COMPLETED_CHILD_COUNT = 1;
@Injectable()
export class TbankSyncService {
@@ -59,16 +45,10 @@ export class TbankSyncService {
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly ledger: BrokerageOperationsService,
private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService,
private readonly accounts: BrokerageAccountsService,
private readonly income: IncomeAnalyticsService,
private readonly events: UpcomingEventsService,
private readonly stockIncome: StockIncomeService,
private readonly bondIncome: BondIncomeService,
) {}
async request(userId: string, input: InvestmentScreenDto) {
@@ -368,7 +348,6 @@ export class TbankSyncService {
: children.some((child) => child.state === 'cancelled')
? 'cancelled'
: 'succeeded';
const resources = await this.observe(userId, intent.scope);
if (
(await this.access.local(userId, intent.scope.accountId)).revision !==
connection.revision
@@ -380,12 +359,6 @@ export class TbankSyncService {
...intent,
state,
errorCode: failed,
progress: {
completed: children.filter((child) => child.state !== 'running').length,
total: children.length,
discovering: !saved.progress.dispatched,
},
resources,
};
}
@@ -393,106 +366,6 @@ export class TbankSyncService {
userId: string,
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> {
const resources: InvestmentResourceMetaDto[] = [];
const input = work.intent as Record<string, unknown>;
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
if (work.scopeKey === 'membership') {
await add(this.accounts.list(userId, true));
} else if (
input.source === 'portfolio' &&
typeof input.accountId === 'string'
) {
await add(this.portfolio.read(userId, input.accountId, true));
} else if (input.source === 'catalog') {
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
if (typeof input.query === 'string') {
await add(
this.discovery.search(
userId,
{
instrumentType: type,
query: input.query,
exactTicker: input.exactTicker === true,
},
true,
),
);
} else if (
typeof input.uid === 'string' &&
typeof input.kind === 'string'
) {
const range =
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
? (JSON.parse(input.rangeKey) as string[])
: null;
const sources = await this.sources.readMany(
userId,
type,
[input.uid],
true,
[
input.kind as Parameters<
CatalogEnrichmentService['readMany']
>[4] extends (infer K)[] | undefined
? K
: never,
],
range ? { from: range[0], to: range[1] } : undefined,
);
resources.push(...sources.map((source) => source.meta));
}
} else if (input.source === 'metadata') {
const identity = {
instrumentUid:
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
figi: typeof input.figi === 'string' ? input.figi : null,
};
const source = await this.metadata.read(userId, identity, true);
resources.push(
investmentResourceMeta({
resourceKey: work.scopeKey,
dataRevision: source?.dataRevision ?? '0',
availability: source?.complete ? 'complete' : 'missing',
fetchedAt: source?.fetchedAt ?? null,
freshForMs: Number.POSITIVE_INFINITY,
work,
}),
);
} else if (
work.scopeKey.startsWith('candles:') &&
typeof input.uid === 'string'
) {
await add(
this.candles.read(
userId,
{
uid: input.uid,
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
period: input.period as 'all' | 'halfYear' | 'month' | 'year',
},
true,
),
);
} else if (
work.scopeKey.startsWith('operations:') &&
Array.isArray(input.intervals)
) {
for (const key of input.intervals as string[]) {
const [accountId, from, end, instrumentUid] = JSON.parse(
key,
) as string[];
await add(
this.ledger.read(userId, accountId, {
from,
to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(),
...(instrumentUid ? { instrumentUid } : {}),
sync: 'observe',
}),
);
}
}
const observed = work.scopeKey.startsWith('operations:')
? await this.operations.observe(userId, work.id)
: work;
@@ -515,112 +388,6 @@ export class TbankSyncService {
now: work.createdAt.toISOString(),
state,
errorCode: observed.errorCode,
progress: {
completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT,
total: 1,
discovering: false,
},
resources,
};
}
private async observe(userId: string, scope: InvestmentScreenDto) {
const resources: InvestmentResourceMetaDto[] = [];
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
const account = scope.accountId ?? 'all';
if (scope.screen === 'accounts') {
await add(this.accounts.list(userId, true));
}
if (scope.screen === 'account') {
await add(this.portfolio.read(userId, account, true));
await add(this.income.summary(userId, account, true));
await add(this.events.get(userId, account, { sync: 'observe' }));
}
if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) {
await add(
this.ledger.read(userId, account, {
from: scope.from,
to: scope.to,
instrumentUid: scope.instrumentUid,
categories: scope.categories,
limit: scope.limit,
sync: 'observe',
}),
);
}
if (scope.screen === 'income') {
await add(
this.income.analytics(userId, account, scope.from, scope.to, true),
);
}
if (scope.screen === 'events') {
await add(
this.events.get(userId, account, {
from: scope.from,
to: scope.to,
sync: 'observe',
}),
);
}
const type =
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
if (scope.screen === 'stocks' || scope.screen === 'bonds') {
await add(
this.discovery.search(
userId,
{ instrumentType: type, query: scope.query ?? '' },
true,
),
);
}
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
const uid = scope.instrumentUid;
await add(this.discovery.detail(userId, type, uid, true));
await add(this.sources.section(userId, type, uid, 'quote', true));
await add(
this.sources.section(
userId,
type,
uid,
type === 'share' ? 'company' : 'metrics',
true,
),
);
await add(
this.candles.read(
userId,
{
uid,
instrumentType: type,
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
true,
),
);
await add(
type === 'share'
? this.stockIncome.readPosition(userId, uid, true)
: this.bondIncome.readPosition(userId, uid, true),
);
await add(
type === 'share'
? this.stockIncome.dividends(userId, uid, true)
: this.bondIncome.coupons(userId, uid, true),
);
}
return resources;
}
}
@@ -0,0 +1,69 @@
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
import {
decimalFromString,
formatDecimal,
multiply,
} from '../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
import { decimal } from '../../../integrations/tbank/decimal';
import { UpcomingEventDto } from './upcoming-events.dto';
export function dividendEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
.toISOString()
.slice(ISO_DATE_START, ISO_DATE_LENGTH),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
export function bondEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
projectedAmount:
'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
};
}
function projectedMoney(
quantity: ReturnType<typeof decimal>,
value: string,
currency: string | undefined,
): UpcomingEventDto['projectedAmount'] {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency) return null;
return {
currency: normalizedCurrency,
value: formatDecimal(multiply(quantity, decimalFromString(value))),
};
}
@@ -1 +0,0 @@
export { UpcomingEventsService } from './upcoming-events.service';
@@ -1,5 +1,5 @@
import { decimalFromString } from '../../../common/decimal';
import { bondEvent, dividendEvent } from './upcoming-events.service';
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
@@ -1,22 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
} from '../../../common/decimal';
import { MoneyDto } from '../../../common/money.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
import { decimal } from '../../../integrations/tbank/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import {
InstrumentMetadataService,
metadataKey,
TbankAccessService,
type TbankDividend,
} from '../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -25,6 +20,7 @@ import {
type PortfolioPositionDto,
PortfolioService,
} from '../portfolio/portfolio.api';
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
import {
UpcomingEventDto,
UpcomingEventsDto,
@@ -211,51 +207,6 @@ export class UpcomingEventsService {
}
}
export function dividendEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: TbankDividend,
): UpcomingEventDto {
return {
...metadata,
type: 'dividend',
date: new Date(Number(event.recordDate?.seconds) * MILLISECONDS_PER_SECOND)
.toISOString()
.slice(ISO_DATE_START, ISO_DATE_LENGTH),
dateKind: 'registry',
projectedAmount: event.dividendNet
? projectedMoney(
quantity,
formatDecimal(decimal(event.dividendNet)),
event.dividendNet.currency,
)
: null,
};
}
export function bondEvent(
metadata: Pick<
UpcomingEventDto,
'displayName' | 'figi' | 'instrumentUid' | 'isin' | 'logoName' | 'ticker'
>,
quantity: ReturnType<typeof decimal>,
event: MoexBondEvent,
): UpcomingEventDto {
return {
...metadata,
type: event.type,
date: event.date,
dateKind: 'event',
projectedAmount:
'value' in event
? projectedMoney(quantity, event.value, event.currency)
: null,
};
}
function supported(position: Position): boolean {
return (
!isZero(decimalFromString(position.quantity)) &&
@@ -274,21 +225,8 @@ function dateWindow(query: UpcomingEventsQueryDto) {
return { from, to };
}
function projectedMoney(
quantity: ReturnType<typeof decimal>,
value: string,
currency: string | undefined,
): MoneyDto | null {
const normalizedCurrency = currency?.trim().toUpperCase();
if (!normalizedCurrency) return null;
return {
currency: normalizedCurrency,
value: formatDecimal(multiply(quantity, decimalFromString(value))),
};
}
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimal>>();
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
continue;
+27 -17
View File
@@ -1,12 +1,13 @@
import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import { PinoLogger } from 'nestjs-pino';
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import request from 'supertest';
import { App } from 'supertest/types';
import { AppModule } from '../src/app.module';
import { StructuredLogger } from '../src/common/logging/structured-logger.service';
import { loggingOptions } from '../src/common/logging/logging.module';
import { configureApp } from '../src/main';
import { AuthService } from '../src/modules/auth/application/auth.service';
import { PrismaService } from '../src/prisma/prisma.service';
@@ -16,10 +17,22 @@ type Problem = { code: string };
describe('Authentication (e2e)', () => {
let app: INestApplication<App>;
const logs: Record<string, unknown>[] = [];
beforeEach(async () => {
logs.length = 0;
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
}).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({
inject: [ConfigService],
factory: (config: Parameters<typeof loggingOptions>[0]) => ({
pinoHttp: {
...loggingOptions(config),
level: 'info',
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
},
assignResponse: true,
}),
}).compile();
app = moduleFixture.createNestApplication();
@@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => {
]);
const winner = responses.find((response) => response.status === 200);
if (!winner) throw new Error('Expected one successful rotation');
expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined();
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(401);
.expect(401)
.expect((response) => expect(response.headers['set-cookie']).toBeUndefined());
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', winner.headers['set-cookie'][0])
@@ -304,8 +319,6 @@ describe('Authentication (e2e)', () => {
});
it('exposes a server request ID and logs one completion event', async () => {
const logger = app.get(StructuredLogger);
const event = jest.spyOn(logger, 'event');
const response = await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Origin', 'http://localhost:5173')
@@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => {
expect(response.headers['access-control-expose-headers']).toContain(
'X-Request-ID',
);
expect(event).toHaveBeenCalledTimes(1);
expect(event).toHaveBeenCalledWith(
'warn',
'http.request.completed',
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
);
expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
]);
});
it('logs an unhandled error with safe public context and correlation', async () => {
const error = jest.spyOn(PinoLogger.prototype, 'error');
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
jest
.spyOn(app.get(AuthService), 'login')
@@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => {
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
);
expect(error).toHaveBeenCalledTimes(1);
expect(error).toHaveBeenCalledWith(
const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
expect(completed).toEqual([
expect.objectContaining({
requestId: response.headers['x-request-id'],
clientRequestId,
status: 500,
code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.completed',
}),
'http.request.completed',
);
expect(JSON.stringify(error.mock.calls)).not.toContain(
]);
expect(JSON.stringify(logs)).not.toContain(
'SYNTHETIC_AUDIT_SECRET',
);
});
+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();
+3 -1
View File
@@ -7,12 +7,13 @@ const cwd = resolve(__dirname, '..');
const available = readdirSync(__dirname)
.filter((name) => name.endsWith('.e2e-spec.ts'))
.sort();
const performanceSuites = new Set(['tbank-read-baseline.e2e-spec.ts']);
const requested = process.argv.slice(2);
const suites =
requested.length === 0
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
: requested.length === 1 && requested[0] === '--all'
? available
? available.filter((suite) => !performanceSuites.has(suite))
: requested;
if (suites.some((suite) => !available.includes(suite))) {
throw new Error('Unknown e2e suite');
@@ -63,6 +64,7 @@ function run(args, env) {
WL_E2E_DIRECTORY: dir,
NODE_ENV: 'development',
NODE_OPTIONS: '--experimental-vm-modules',
LOG_LEVEL: 'error',
};
console.log(`SQLite ${suite}: ${dir}`);
try {
+10 -43
View File
@@ -41,7 +41,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
lastSuccessfulAt: null,
resourceKey: 'detail/uid',
dataRevision: '1',
stateVersion: 'detail-v1',
sync: { id: null, state: 'idle', errorCode: null },
},
},
@@ -273,7 +272,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
).toBe(abandoned?.work.attemptId);
await reopened.read('owner', input);
await settled();
expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1);
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
expect(
(await repository.work('owner', 'revision', 'candles:uid:month'))
?.attemptId,
@@ -483,52 +482,20 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
).toBe(1);
});
it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => {
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'opened',
instrumentType: 'share',
metadata: {},
fetchedAt: new Date(),
},
});
it('loads only the requested period and reuses its saved coverage', async () => {
const service = createService();
let release: (value: unknown[]) => void = () => {};
const blocked = new Promise<unknown[]>((resolve) => {
release = resolve;
});
upstream.getCandles.mockReturnValueOnce(blocked);
await service.prefetch('owner', 'revision', [
{ instrumentUid: 'uid', type: 'STOCK' },
{ instrumentUid: 'uid', type: 'STOCK' },
{ instrumentUid: 'not-supported', type: 'FUND' },
{ instrumentUid: null, type: 'BOND' },
]);
for (let i = 0; i < 100 && !upstream.getCandles.mock.calls.length; i++) {
await delay(5);
}
expect((upstream.getCandles.mock.calls as unknown[][])[0]?.[1]).toBe('uid');
await service.read('owner', {
uid: 'opened',
const input = {
uid: 'uid',
instrumentType: 'share',
period: 'month',
});
release([]);
} as const;
await service.read('owner', input);
await settled();
expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe(
'opened',
);
expect(
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
).toEqual(new Set(['opened', 'uid']));
expect(upstream.getCandles).toHaveBeenCalledTimes(1);
expect(await repository.work('owner', 'revision', 'candles:uid:all')).toBeNull();
const count = upstream.getCandles.mock.calls.length;
await service.prefetch('owner', 'revision', [
{ instrumentUid: 'uid', type: 'STOCK' },
]);
await service.read('owner', input);
await settled();
expect(upstream.getCandles).toHaveBeenCalledTimes(count);
expect(
(await repository.work('owner', 'revision', 'candles:opened:all'))?.state,
).toBe('complete');
});
});
+4 -5
View File
@@ -263,7 +263,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
},
);
it('keeps status versions stable between observational reads of a moving period', async () => {
it('keeps data revisions stable while observational reads expose current coverage', async () => {
const observe = () =>
request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100')
@@ -276,7 +276,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
expect(second.meta.coverage?.requested).not.toEqual(
first.meta.coverage?.requested,
);
expect(second.meta.stateVersion).toBe(first.meta.stateVersion);
expect(second.meta.dataRevision).toBe(first.meta.dataRevision);
await prisma.tbankOperationCoverage.updateMany({
data: {
to: new Date(Date.now() - 60_000),
@@ -285,10 +285,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
});
const changed = (await observe().expect(200))
.body as BrokerageOperationsResourceDto;
expect(changed.meta.stateVersion).not.toBe(second.meta.stateVersion);
expect(changed.meta.coverage?.asOf).not.toBe(second.meta.coverage?.asOf);
expect(upstream.getOperationsByCursor).not.toHaveBeenCalled();
});
it('reads shared metadata from SQLite and changes stateVersion without invalidating a row cursor', async () => {
it('reads shared metadata from SQLite without invalidating a row cursor', async () => {
const before = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto;
assert(before.data?.nextCursor);
@@ -323,7 +323,6 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
const after = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto;
expect(after.data?.items[0].logoName).toBe('saved.png');
expect(after.meta.stateVersion).not.toBe(before.meta.stateVersion);
expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
await get(
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`,
+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
-6
View File
@@ -134,12 +134,6 @@ describe('T-Bank persisted read budget', () => {
'bond',
instruments.find((item) => item.uid === uid),
),
getLastPrice: (_token: string, uid: string) =>
counted('lastPrice', {
instrumentUid: uid,
price: { units: '100' },
time: { seconds: Date.UTC(2026, 7, 31) / 1000 },
}),
getLastPrices: (_token: string, uids: string[]) =>
counted(
'lastPrices',
+6 -10
View File
@@ -103,10 +103,11 @@ describe('screen sync HTTP and SQLite', () => {
release([]);
const done = await settled(ids[0]);
expect(done.state).toBe('succeeded');
expect(done.resources[0]).toMatchObject({
resourceKey: 'accounts',
availability: 'complete',
});
const accounts = await request(app.getHttpServer())
.get('/api/v1/brokerage-accounts?sync=observe')
.set('Authorization', auth())
.expect(200);
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
expect(JSON.stringify(done)).not.toMatch(
/revision|attemptId|encryptedToken|synthetic/,
);
@@ -297,20 +298,17 @@ describe('screen sync HTTP and SQLite', () => {
.set('Authorization', auth())
.expect(200);
const first = before.body as {
meta: { dataRevision: string; stateVersion: string };
meta: { dataRevision: string };
};
const last = after.body as {
data: { items: unknown[] };
meta: {
dataRevision: string;
stateVersion: string;
sync: { id: string };
};
};
expect(last.data.items).toEqual([]);
expect(last.meta.dataRevision).toBe(first.meta.dataRevision);
expect(last.meta.stateVersion).not.toBe(first.meta.stateVersion);
expect(done.resources[0].stateVersion).toBe(last.meta.stateVersion);
await status(last.meta.sync.id).expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
});
@@ -389,7 +387,6 @@ describe('screen sync HTTP and SQLite', () => {
try {
expect((await status(second).expect(200)).body).toMatchObject({
state: 'running',
progress: { completed: 1, total: 2, discovering: false },
});
await status(second, 'other').expect(404);
} finally {
@@ -401,7 +398,6 @@ describe('screen sync HTTP and SQLite', () => {
expect(await settled(second)).toMatchObject({
state: outcome,
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
progress: { completed: 2, total: 2, discovering: false },
});
await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await status(second).expect(404);