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` Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers. для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
request context и ограничивает поля событий. Jest unit tests, изолированные context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения. SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники). Локальные источники выбираются через [Источники](../AGENTS.md#источники).
@@ -267,5 +267,8 @@ Tests сохраняют независимые literal wire assertions; обы
## Проверка ## Проверка
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case. `npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
Смысл boundaries проверяется review: backend не имеет отдельного автоматического ESLint проверяет прямые imports: domain не зависит от transport/persistence,
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner. controllers делегируют application, use cases не импортируют Prisma. Чистые
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
+4
View File
@@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и [validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts). [TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client. gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC; - `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой. `warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion. Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный - Native `nestjs-pino` context и `pino-http` completion владеют correlation:
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно. server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service. Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords, - StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data. allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace 401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе. сессию и очищает 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 verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change. `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 получает новую временную `npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов. базу со штатными 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` `prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска — на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных). [Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
+3 -1
View File
@@ -60,7 +60,9 @@ jobs:
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Audit runtime and development dependencies - 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: verify:
needs: [commits, security] needs: [commits, security]
+71
View File
@@ -1,5 +1,76 @@
# Changelog # 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) ## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
### Bug Fixes ### Bug Fixes
+16 -5
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint, Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки на новых базах и соответствие генерируемого 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`, Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6 включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -81,12 +84,16 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client, 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 записывается в лог. backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми 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` перечисляет только проверенные исторические `.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter. совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются. Новые коммиты этими исключениями не покрываются.
@@ -150,6 +157,10 @@ lint. Он не защищает от усложнения уже учтённо
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены. для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
Рост baseline — изменение quality gate: он требует отдельного обоснования
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
## SonarQube ## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
+2 -7
View File
@@ -4,11 +4,6 @@
"count": 1 "count": 1
} }
}, },
"src/common/logging/request-logging.middleware.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": { "src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -54,7 +49,7 @@
}, },
"src/modules/investments/instruments/application/candle.service.ts": { "src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 4 "count": 3
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 4 "count": 4
@@ -169,7 +164,7 @@
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": { "src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 3 "count": 1
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 7 "count": 7
+49
View File
@@ -15,6 +15,9 @@ import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts']; const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
];
export default defineConfig( export default defineConfig(
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']), globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
@@ -119,6 +122,52 @@ export default defineConfig(
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }], 'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
}, },
}, },
{
files: ['src/modules/**/domain/**/*.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: [
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
'**/*.client', '**/application/**', '**/infrastructure/**',
],
message: 'Domain uses pure rules and value types, not transport or persistence.',
}],
}],
},
},
{
files: ['src/**/*.controller.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: [...prismaImports, '**/*.repository', '**/*.client'],
message: 'Controllers delegate to application services or use cases.',
},
{
group: ['**'],
importNamePattern: '(?:Client|Repository|PrismaService)$',
message: 'Use application orchestration rather than data providers in controllers.',
},
],
}],
},
},
{
files: ['src/**/*.use-case.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: prismaImports,
message: 'Use cases obtain data through concrete repositories.',
}],
}],
},
},
{ {
files: [ files: [
'src/**/*.ts', 'src/**/*.ts',
+61 -226
View File
@@ -1,15 +1,15 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.2.3", "version": "2.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.2.3", "version": "2.0.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.0",
@@ -1425,29 +1425,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24" "node": "^20.19.0 || ^22.13.0 || >=24"
} }
}, },
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": { "node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -1536,9 +1513,9 @@
} }
}, },
"node_modules/@grpc/grpc-js": { "node_modules/@grpc/grpc-js": {
"version": "1.14.4", "version": "1.14.5",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==", "integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@grpc/proto-loader": "^0.8.0", "@grpc/proto-loader": "^0.8.0",
@@ -2186,6 +2163,13 @@
"node": ">=8" "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": { "node_modules/@istanbuljs/schema": {
"version": "0.1.6", "version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz", "resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -2797,29 +2781,6 @@
"url": "https://github.com/sponsors/epoberezkin" "url": "https://github.com/sponsors/epoberezkin"
} }
}, },
"node_modules/@nestjs/cli/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@nestjs/cli/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@nestjs/cli/node_modules/chokidar": { "node_modules/@nestjs/cli/node_modules/chokidar": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -5095,29 +5056,6 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -6270,6 +6208,16 @@
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0" "@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
} }
}, },
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": { "node_modules/base64-js": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6304,9 +6252,9 @@
} }
}, },
"node_modules/basic-ftp": { "node_modules/basic-ftp": {
"version": "5.3.1", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz", "resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==", "integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -6415,6 +6363,19 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browserslist": { "node_modules/browserslist": {
"version": "4.28.8", "version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -6506,6 +6467,7 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/builtin-modules": { "node_modules/builtin-modules": {
@@ -7019,6 +6981,7 @@
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz", "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==", "integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"dev": true,
"engines": [ "engines": [
"node >= 6.0" "node >= 6.0"
], ],
@@ -8358,29 +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": { "node_modules/eslint-plugin-import-x/node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -8587,29 +8527,6 @@
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0" "eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
} }
}, },
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": { "node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -8658,29 +8575,6 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": { "node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
@@ -9053,9 +8947,9 @@
} }
}, },
"node_modules/fast-uri": { "node_modules/fast-uri": {
"version": "3.1.7", "version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [ "funding": [
{ {
"type": "github", "type": "github",
@@ -9537,29 +9431,6 @@
"node": ">=10.13.0" "node": ">=10.13.0"
} }
}, },
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": { "node_modules/glob/node_modules/minimatch": {
"version": "10.2.6", "version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9967,9 +9838,9 @@
} }
}, },
"node_modules/ip-address": { "node_modules/ip-address": {
"version": "10.7.0", "version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", "integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
@@ -11641,14 +11512,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/multer": { "node_modules/multer": {
"version": "2.3.0", "version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz", "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==", "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"append-field": "^1.0.0", "append-field": "^1.0.0",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18" "type-is": "^1.6.18"
}, },
"engines": { "engines": {
@@ -12829,9 +12699,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
@@ -12839,6 +12709,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/proxy-agent": { "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": { "node_modules/remeda": {
"version": "2.33.4", "version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz", "resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -13803,13 +13667,6 @@
"node": ">= 10.x" "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": { "node_modules/sql-escaper": {
"version": "1.5.1", "version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz", "resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
@@ -14181,29 +14038,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/glob": { "node_modules/test-exclude/node_modules/glob": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz", "resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -14234,9 +14068,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": { "node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.4", "version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", "integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
@@ -14633,6 +14467,7 @@
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==", "integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/typescript": { "node_modules/typescript": {
+9 -3
View File
@@ -1,6 +1,6 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.2.3", "version": "2.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -26,6 +26,7 @@
"test:cov": "npm test -- --coverage", "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: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: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:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
@@ -37,7 +38,7 @@
"test:lint": "node --test test/eslint-config.cjs" "test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.0",
@@ -104,12 +105,17 @@
"typescript-eslint": "8.70.1" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8",
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.3.0" "multer": "2.4.0"
}, },
"@prisma/config@7.10.0": { "@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2" "deepmerge-ts": "8.0.2"
}, },
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
},
"prisma": { "prisma": {
"mysql2": "3.24.4" "mysql2": "3.24.4"
} }
-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. // 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(); 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') const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
+2 -36
View File
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; 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 { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true, isGlobal: true,
validate: validateEnv, 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, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, 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 { Global, Module } from '@nestjs/common';
import { RequestLoggingMiddleware } from './request-logging.middleware'; 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'; 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() @Global()
@Module({ @Module({
providers: [ imports: [LoggerModule.forRootAsync<Request, Response>({
RequestContextService, inject: [ConfigService],
RequestLoggingMiddleware, useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
StructuredLogger, })],
], providers: [StructuredLogger],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger], exports: [StructuredLogger],
}) })
export class LoggingModule {} 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'; import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => { describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = { const pino = {
debug: jest.fn(), debug: jest.fn(),
info: jest.fn(), info: jest.fn(),
@@ -12,8 +11,7 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => { it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' }); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.info('event', { logger.info('event', {
userId: 'user', userId: 'user',
@@ -23,7 +21,6 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
requestId: 'request',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -32,8 +29,7 @@ describe('StructuredLogger', () => {
}); });
it('delegates event levels to Pino', () => { it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.event('debug', 'debug event'); logger.event('debug', 'debug event');
logger.event('warn', 'warn event'); logger.event('warn', 'warn event');
@@ -1,8 +1,6 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'error' | 'info' | 'warn'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
@@ -27,10 +25,7 @@ const allowedFields = new Set([
@Injectable() @Injectable()
export class StructuredLogger implements OnApplicationShutdown { export class StructuredLogger implements OnApplicationShutdown {
constructor( constructor(private readonly pino: PinoLogger) {}
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
this.write('info', message, context); this.write('info', message, context);
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext(context),
message, message,
); );
} }
+12 -48
View File
@@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, 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) => { async (code) => {
requestRpc requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999); await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2); expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument); await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0); 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; queue.size = 50;
requestRpc.mockResolvedValue({ instrument }); requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi'); await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(1999);
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
queue.size = 49; expect(jest.getTimerCount()).toBe(0);
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');
}); });
it('does not bypass queue admission after an upstream failure', async () => { it('does not bypass queue admission after an upstream failure', async () => {
@@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50; queue.size = 50;
await jest.advanceTimersByTimeAsync(2000); await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1); expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0; expect(await result).toBeInstanceOf(ServiceUnavailableException);
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(jest.getTimerCount()).toBe(0); 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 }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
await Promise.all([ await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), 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); expect(jest.getTimerCount()).toBe(0);
}); });
+9 -27
View File
@@ -46,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50; const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000; const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
@@ -419,12 +417,6 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).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> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
@@ -486,8 +478,7 @@ export class TbankClient {
request: object, request: object,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
@@ -508,34 +499,25 @@ export class TbankClient {
const failureContext = queueFull const failureContext = queueFull
? { code: TbankErrorCode.Unavailable } ? { code: TbankErrorCode.Unavailable }
: { grpcCode }; : { grpcCode };
const retryable = queueFull const retryable = !queueFull && [
? metadataCall status.DEADLINE_EXCEEDED,
: [ status.RESOURCE_EXHAUSTED,
status.DEADLINE_EXCEEDED, status.UNAVAILABLE,
status.RESOURCE_EXHAUSTED, ].includes(grpcCode);
status.UNAVAILABLE, if (retryable && attempts < RPC_MAX_ATTEMPTS) {
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue; continue;
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( if (
-3
View File
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; 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 { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
}); });
app.setGlobalPrefix(API_PREFIX); app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser()); app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidUnknownValues: true, 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 { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthErrorCode } from './auth-error-code'; import { AuthErrorCode } from './auth-error-code';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1; const EXPECTED_ROTATED_SESSIONS = 1;
@@ -35,7 +35,7 @@ export class AuthService {
async register( async register(
email: string, email: string,
password: string, password: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { ): Promise<AuthenticationResult> {
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
@@ -64,7 +64,7 @@ export class AuthService {
async login( async login(
email: string, email: string,
password: string, password: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { ): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email }, where: { email },
}); });
@@ -80,7 +80,7 @@ export class AuthService {
async refresh( async refresh(
token: string, token: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { ): Promise<AuthenticationResult> {
let payload: RefreshPayload; let payload: RefreshPayload;
try { try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
@@ -151,7 +151,7 @@ export class AuthService {
private async createSession( private async createSession(
user: User, user: User,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { ): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({ const session = await this.prisma.session.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -166,7 +166,7 @@ export class AuthService {
private async rotateSession( private async rotateSession(
session: Session & { user: User }, session: Session & { user: User },
expectedRefreshTokenHash?: string, expectedRefreshTokenHash?: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> { ): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync( const refreshToken = await this.jwt.signAsync(
{ {
sub: session.userId, sub: session.userId,
@@ -3,6 +3,12 @@ export interface AuthUser {
id: string; id: string;
} }
export type AuthenticationResult = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export interface AccessPayload { export interface AccessPayload {
sid: string; sid: string;
sub: string; sub: string;
@@ -23,7 +23,7 @@ import {
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service'; import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto'; import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
@@ -85,14 +85,9 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { const token = this.refreshToken(request);
const token = this.refreshToken(request); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token));
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
} }
@Post('logout') @Post('logout')
@@ -117,11 +112,7 @@ export class AuthController {
private respond( private respond(
response: Response, response: Response,
result: { result: AuthenticationResult,
accessToken: string;
refreshToken: string;
user: { email: string; id: string };
},
) { ) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken }; return { user: result.user, accessToken: result.accessToken };
@@ -1,12 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) { constructor(private readonly logger: PinoLogger) {
super(); super();
} }
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) { if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.logger.assign({ userId: request.user.sub });
} }
return Boolean(allowed); return Boolean(allowed);
} }
@@ -103,13 +103,9 @@ export class BankCardsService {
id, id,
name: dto.name ?? previous.name, name: dto.name ?? previous.name,
bankName: bankName:
dto.bankName === undefined dto.bankName === undefined ? previous.bankName : dto.bankName,
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour: lastFour:
dto.lastFour === undefined dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
paymentSystem: paymentSystem:
dto.paymentSystem === undefined dto.paymentSystem === undefined
? previous.paymentSystem ? previous.paymentSystem
@@ -49,7 +49,7 @@ export class BankCardsRepository {
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /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; continue;
} }
if (busy) conflict(BankErrorCode.WriteConflict); 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']); ).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 () => { it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation( operations.collectRanges.mockImplementation(
( (
@@ -232,13 +232,16 @@ function buckets(
granularity: IncomeBucketGranularity, granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[], entries: BrokerageOperationDto[],
) { ) {
const datedEntries = entries.map((entry) => ({
entry,
date: moscowDate(new Date(entry.occurredAt)),
}));
return rangeBuckets(range, granularity).map((period) => ({ return rangeBuckets(range, granularity).map((period) => ({
period, period,
totalsByCurrency: totals( totalsByCurrency: totals(
entries.filter((entry) => { datedEntries
const date = moscowDate(new Date(entry.occurredAt)); .filter(({ date }) => date >= period.from && date <= period.to)
return 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'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { formatDecimal } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
@@ -17,7 +15,6 @@ import {
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { planInvestmentScreen } from '../../investment-sync-plan';
import { import {
candleGaps, candleGaps,
type CandleRange, type CandleRange,
@@ -26,11 +23,10 @@ import {
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { CandleRepository } from '../infrastructure/candle.repository'; import { CandleRepository } from '../infrastructure/candle.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market'; import { timestamp } from './instrument-market';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1; const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1; const NEXT_WINDOW_OFFSET = 1;
@@ -56,7 +52,6 @@ type Producer = {
}; };
const months = { month: 1, halfYear: 6, year: 12 }; const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000; const tailFreshForMs = 60_000;
const prefetchReadBatchSize = 100;
function scope(input: CandleInput) { function scope(input: CandleInput) {
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`; 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 readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve(); private reservations: Promise<unknown> = Promise.resolve();
private running = false; private running = false;
private readonly prefetches = new Map<string, Promise<void>>();
constructor( constructor(
private readonly repository: CandleRepository, private readonly repository: CandleRepository,
private readonly sources: CatalogEnrichmentService, 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( request(
userId: string, userId: string,
revision: string, revision: string,
@@ -413,17 +303,7 @@ export class CandleService {
window.from, window.from,
new Date(Math.min(window.to.valueOf(), now.valueOf())), new Date(Math.min(window.to.valueOf(), now.valueOf())),
); );
const rows = candles.flatMap((candle) => { const rows = candles.map(toSavedCandle);
const time = timestamp(candle.time);
if (!time || !candle.close) throw new Error('Invalid candle response');
return [
{
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
},
];
});
if (!(await this.repository.stage(attempt, window, rows, now))) { if (!(await this.repository.stage(attempt, window, rows, now))) {
return false; return false;
} }
@@ -510,17 +390,6 @@ export class CandleService {
this.reservations = finished.catch(() => undefined); this.reservations = finished.catch(() => undefined);
if (!(await finished)) { if (!(await finished)) {
this.queue.push(producer); 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 { } catch {
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
import { import {
decimalFromString, decimalFromString,
divide, divide,
formatDecimal,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { import {
@@ -13,7 +12,6 @@ import {
} from '../../../../common/time'; } from '../../../../common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
@@ -37,6 +35,7 @@ import {
catalogScope, catalogScope,
type CatalogSource, type CatalogSource,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market'; import { previousClose, timestamp } from './instrument-market';
@@ -353,17 +352,7 @@ export class CatalogEnrichmentService {
gap.from, gap.from,
gap.to, gap.to,
); );
const rows = response.map((candle) => { const rows = response.map(toSavedCandle);
const time = timestamp(candle.time);
if (!time || !candle.close) {
throw new Error('Invalid candle response');
}
return {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
};
});
if (!(await this.candles.stage(attempt, gap, rows, now))) { if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison'); throw new Error('Cancelled candle comparison');
} }
@@ -12,6 +12,7 @@ import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
type TbankCoupon, type TbankCoupon,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
@@ -122,6 +123,10 @@ export class BondIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, 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 return result.data || !projected
? result ? result
: { : {
@@ -53,12 +53,6 @@ export class CandleRepository {
}); });
} }
coverage(uids: string[]) {
return this.prisma.tbankCandleCoverage.findMany({
where: { provider, instrumentUid: { in: uids }, interval },
});
}
reserve( reserve(
owner: { revision: string; userId: string }, owner: { revision: string; userId: string },
scopeKey: 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 { import {
TbankAccessService, TbankAccessService,
type TbankDividend, type TbankDividend,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
@@ -91,6 +92,10 @@ export class StockIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, 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 return result.data || !projected
? result ? result
: { : {
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const; export const availabilityValues = ['missing', 'partial', 'complete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] 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 { export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] }) @ApiPropertyOptional({ enum: ['observe'] })
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
@ApiProperty() @ApiProperty()
declare dataRevision: string; declare dataRevision: string;
@ApiProperty()
declare stateVersion: string;
@ApiPropertyOptional({ type: String }) @ApiPropertyOptional({ type: String })
unavailableReason?: string; unavailableReason?: string;
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
const stale = investmentResourceMeta(input); const stale = investmentResourceMeta(input);
expect(stale.freshness).toBe('stale'); expect(stale.freshness).toBe('stale');
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt); expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
expect( expect(
investmentResourceMeta({ investmentResourceMeta({
...input, ...input,
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
}); });
expect(terminal.sync).toEqual(failed.sync); expect(terminal.sync).toEqual(failed.sync);
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt); expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
expect( expect(
investmentResourceMeta({ investmentResourceMeta({
...input, ...input,
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { InvestmentResourceMetaDto } from './investment-resource.dto'; import { InvestmentResourceMetaDto } from './investment-resource.dto';
export function investmentResourceMeta(input: { export function investmentResourceMeta(input: {
@@ -31,7 +29,6 @@ export function investmentResourceMeta(input: {
]; ];
const sync = const sync =
states.find((state) => state.state === 'running') ?? states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'queued') ??
states.find((state) => state.state === 'failed') ?? states.find((state) => state.state === 'failed') ??
ownSync; ownSync;
const sourceFreshness = input.fetchedAt const sourceFreshness = input.fetchedAt
@@ -71,19 +68,5 @@ export function investmentResourceMeta(input: {
...(input.coverage ? { coverage: input.coverage } : {}), ...(input.coverage ? { coverage: input.coverage } : {}),
...(input.dependencies ? { dependencies: input.dependencies } : {}), ...(input.dependencies ? { dependencies: input.dependencies } : {}),
}; };
return { return meta;
...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'),
};
} }
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
priority: 'screen', 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)) { if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
sources.push( sources.push(
@@ -19,6 +19,7 @@ import {
} from '../../investment-sync-plan'; } from '../../investment-sync-plan';
import { OperationErrorCode } from '../domain/operation-error-code'; import { OperationErrorCode } from '../domain/operation-error-code';
import { import {
confirmedOperationRanges,
operationIntervalsOverlap, operationIntervalsOverlap,
operationsSyncPriority, operationsSyncPriority,
planOperationRead, planOperationRead,
@@ -196,22 +197,7 @@ export class BrokerageOperationsService {
const plan = planOperationRead(targets, saved?.coverage ?? [], now, { const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
movingEnd, movingEnd,
}); });
const confirmed = plan.intervals.flatMap((target) => const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
(saved?.coverage ?? [])
.filter(
(row) =>
row.accountId === target.accountId &&
(!row.instrumentUid ||
row.instrumentUid === target.instrumentUid) &&
row.from < target.to &&
target.from < row.to,
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
const missing = uncoveredOperationIntervals( const missing = uncoveredOperationIntervals(
plan.intervals, plan.intervals,
confirmed.map((range) => ({ confirmed.map((range) => ({
@@ -544,19 +530,7 @@ export class BrokerageOperationsService {
.sort((a, b) => a.valueOf() - b.valueOf())[0] ?? .sort((a, b) => a.valueOf() - b.valueOf())[0] ??
(complete ? saved?.connection.membershipAt : null) ?? (complete ? saved?.connection.membershipAt : null) ??
null; null;
const confirmed = plan.intervals.flatMap((target) => const confirmed = confirmedOperationRanges(plan.intervals, sources);
sources
.filter(
(row) =>
row.accountId === target.accountId &&
operationIntervalsOverlap(row, target),
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods. // eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`, resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
@@ -1,4 +1,30 @@
import { operationIntervals, planOperationRead } from './operations-sync-plan'; import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan';
describe('confirmed operation range projection', () => {
it('clips overlaps in source order without merging and respects account and instrument scope', () => {
const day = (value: number) => new Date(Date.UTC(2026, 0, value));
const target = { accountId: 'a', from: day(2), to: day(5) };
const sources = [
{ ...target, from: day(3), to: day(6) },
{ ...target, from: day(1), to: day(4) },
{ ...target, instrumentUid: 'uid' },
{ ...target, accountId: 'other' },
{ ...target, from: day(5), to: day(6) },
{ ...target, from: day(1), to: day(2) },
];
const before = structuredClone(sources);
expect(confirmedOperationRanges([target], sources)).toEqual([
{ ...target, from: day(3) },
{ ...target, to: day(4) },
]);
expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([
{ ...target, from: day(3) },
{ ...target, to: day(4) },
target,
]);
expect(sources).toEqual(before);
});
});
describe('operations calendar windows', () => { describe('operations calendar windows', () => {
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => { it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
@@ -106,6 +106,24 @@ export function uncoveredOperationIntervals(
}); });
} }
export function confirmedOperationRanges(
targets: OperationInterval[],
coverage: OperationInterval[],
): Omit<OperationInterval, 'instrumentUid'>[] {
return targets.flatMap((target) => coverage
.filter((row) =>
row.accountId === target.accountId &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
row.from < target.to && target.from < row.to,
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
}
export type OperationCoverage = OperationInterval & { export type OperationCoverage = OperationInterval & {
asOf: Date; asOf: Date;
fetchedAt: Date; fetchedAt: Date;
@@ -392,52 +392,15 @@ export class BrokerageOperationsRepository {
const work = await tx.tbankSyncWork.update({ const work = await tx.tbankSyncWork.update({
where: { id: previous.id }, where: { id: previous.id },
data: { data: {
intent: { intent: extendRunningIntent({
atomic: previousIntent?.atomic ?? publicationAtomic, previous: previousIntent,
atomicIntervals: previousIntent?.atomic publicationAtomic,
? [ pending,
...new Set([ additions,
...(previousIntent.atomicIntervals ?? intervals,
previousIntent.intervals), priority,
...additions.map(intervalKey), previousPriority: previous.priority,
]), }),
]
: [],
screenIntervals: [
...new Set([
...(previousIntent?.screenIntervals ??
(previous.priority === operationsSyncPriority.screen
? (previousIntent?.intervals ?? [])
: [])),
...(priority === operationsSyncPriority.screen
? [...pending, ...additions]
.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
)
.map(intervalKey)
: []),
]),
],
intervals: (priority < previous.priority
? [
...pending.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
...additions,
...pending.filter(
(item) =>
!intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
]
: [...pending, ...additions]
).map(intervalKey),
},
priority: Math.min(priority, previous.priority), priority: Math.min(priority, previous.priority),
}, },
}); });
@@ -919,6 +882,40 @@ export class BrokerageOperationsRepository {
} }
} }
function extendRunningIntent(input: {
additions: OperationInterval[];
intervals: OperationInterval[];
pending: OperationInterval[];
previous: OperationsIntent | undefined;
previousPriority: number;
priority: number;
publicationAtomic: boolean;
}): OperationsIntent {
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
const overlapsRequested = (item: OperationInterval) =>
intervals.some((target) => operationIntervalsOverlap(item, target));
const retainedScreen = previous?.screenIntervals ??
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
const addedScreen = priority === operationsSyncPriority.screen
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
: [];
const ordered = priority < previousPriority
? [
...pending.filter(overlapsRequested),
...additions,
...pending.filter((item) => !overlapsRequested(item)),
]
: [...pending, ...additions];
return {
atomic: previous?.atomic ?? publicationAtomic,
atomicIntervals: previous?.atomic
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
: [],
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
intervals: ordered.map(intervalKey),
};
}
function intervalKey(interval: OperationInterval): string { function intervalKey(interval: OperationInterval): string {
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN. // eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) { if (!(interval.from < interval.to)) {
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
ForecastRepository, ForecastRepository,
@@ -10,7 +9,6 @@ import {
} from '../infrastructure/forecast.repository'; } from '../infrastructure/forecast.repository';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@Injectable() @Injectable()
export class ForecastService { export class ForecastService {
@@ -73,23 +71,8 @@ export class ForecastService {
dependencies dependencies
.map((dependency) => dependency.sync) .map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed'); .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 { return {
data, data: stored ? (stored.payload as T) : null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: input.scopeKey, resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
@@ -101,10 +84,7 @@ export class ForecastService {
? 'partial' ? 'partial'
: 'missing', : 'missing',
fetchedAt: first ? new Date(first) : null, fetchedAt: first ? new Date(first) : null,
freshForMs: freshForMs: Number.POSITIVE_INFINITY,
data && 'positionIsStale' in data && data.positionIsStale === true
? STALE_FRESHNESS_MS
: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
dependencies, dependencies,
}), }),
@@ -1,4 +1,3 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { setImmediate as immediate } from 'node:timers/promises'; import { setImmediate as immediate } from 'node:timers/promises';
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
service = new PortfolioService( service = new PortfolioService(
access as never, access as never,
tbank as never, tbank as never,
{ set: jest.fn() } as never,
metadata as never, metadata as never,
repository as never, repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never, { request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never, { event: jest.fn() } as never,
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
); );
access.local.mockResolvedValue({ access.local.mockResolvedValue({
revision: 'private', revision: 'private',
@@ -129,6 +126,43 @@ describe('persistent portfolio projection', () => {
]); ]);
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]); expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
}); });
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({ positions: [
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
{
quantity: { units: '1' },
currentPrice: { currency: 'USD', units: '3' },
averagePositionPrice: { currency: 'RUB', units: '2' },
},
] });
tbank.getPositions.mockResolvedValue({ money: [
{ currency: 'rub', units: '2' },
{ currency: 'RUB', units: '-2' },
{ units: '99' },
{ currency: 'EUR', units: '1' },
] });
await service.request('user', 'private', 'a');
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
await immediate();
}
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.cashBalances).toEqual([
{ currency: 'RUB', value: '0' },
{ currency: 'EUR', value: '1' },
]);
expect(snapshot.totalsByCurrency).toEqual([
{ currency: 'EUR', value: '1' },
{ currency: 'USD', value: '3' },
]);
expect(snapshot.positions[0].currentValue).toBeNull();
expect(snapshot.positions[1].absoluteReturn).toBeNull();
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
});
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
const snapshot: PortfolioDto = { const snapshot: PortfolioDto = {
lastSuccessfulAt: membershipAt.toISOString(), lastSuccessfulAt: membershipAt.toISOString(),
@@ -184,15 +218,4 @@ describe('persistent portfolio projection', () => {
expect(complete.data?.isStale).toBe(true); expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled(); 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 { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
subtract,
} from '../../../../common/decimal';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../../common/money.dto';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
@@ -24,12 +12,11 @@ import {
metadataKey, metadataKey,
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
type TbankInstrument,
TbankMembershipService, TbankMembershipService,
TbankMoney, type TbankMoney,
type TbankPortfolio,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { portfolioStructure } from '../domain/portfolio-structure'; import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals'; import { sumMoneyByCurrency } from '../domain/portfolio-totals';
@@ -38,46 +25,29 @@ import {
PortfolioRepository, PortfolioRepository,
portfolioWorkKey, portfolioWorkKey,
} from '../infrastructure/portfolio.repository'; } from '../infrastructure/portfolio.repository';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper'; import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
import { import { PortfolioDto } from '../presentation/portfolio.dto';
PortfolioDto,
PortfolioPositionDto,
} from '../presentation/portfolio.dto';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const membershipFreshForMs = tbankSourceTtlMs.membership; const membershipFreshForMs = tbankSourceTtlMs.membership;
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly processRunId = randomUUID(); private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>(); private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve(); private reservations: Promise<unknown> = Promise.resolve();
constructor( constructor(
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository, private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService, private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
private readonly candles: CandleService,
) {} ) {}
async saved(userId: string, accountId: string, observe = false) { async saved(userId: string, accountId: string, observe = false) {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const connection = await this.access.local(userId, accountId); const connection = await this.access.local(userId, accountId);
if (!observe) { if (!observe) {
const discovery = await this.membership.request( 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({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -342,7 +290,7 @@ export class PortfolioService {
revision, revision,
accountId, accountId,
this.processRunId, this.processRunId,
this.active, this.pending,
force, force,
); );
if (!requested) return null; if (!requested) return null;
@@ -353,7 +301,6 @@ export class PortfolioService {
}; };
} }
const { id, attemptId } = requested.work; const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve)) const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => { .then(async () => {
const attempt = { id, attemptId, userId, revision }; const attempt = { id, attemptId, userId, revision };
@@ -377,13 +324,6 @@ export class PortfolioService {
for (const position of snapshot.positions) { for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position); await this.metadata.request(userId, revision, position);
} }
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
} }
} catch (error) { } catch (error) {
if ( if (
@@ -400,7 +340,6 @@ export class PortfolioService {
}), }),
) )
.finally(() => { .finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId); this.pending.delete(attemptId);
}); });
this.pending.set(attemptId, done); this.pending.set(attemptId, done);
@@ -410,122 +349,12 @@ export class PortfolioService {
return reserved; 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( private async normalize(
upstream: { upstream: NonNullable<TbankPortfolio['positions']>,
averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: { nano?: number | string; units?: number | string };
}[],
money: TbankMoney[], money: TbankMoney[],
): Promise<PortfolioDto> { ): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'); const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const savedMetadata = await this.metadata.savedMany(upstream); const savedMetadata = await this.metadata.savedMany(upstream);
const positions = upstream.map((position) => return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
this.position(
position,
savedMetadata.get(metadataKey(position))?.data ?? null,
),
);
const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...cash].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}
private position(
position: Parameters<PortfolioService['normalize']>[0][number],
metadata: null | TbankInstrument,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = this.money(position.currentPrice);
const averagePurchasePrice = this.money(position.averagePositionPrice);
const currentValue = currentPrice && this.value(quantity, currentPrice);
const absoluteReturn =
currentPrice &&
averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency
? {
currency: currentPrice.currency,
value: formatDecimal(
multiply(
subtract(
decimalFromString(currentPrice.value),
decimalFromString(averagePurchasePrice.value),
),
quantity,
),
),
}
: null;
return {
figi,
instrumentUid,
displayName:
metadata?.name?.trim() ||
metadata?.ticker?.trim() ||
figi ||
'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
private value(
quantity: ReturnType<typeof decimal>,
price: MoneyDto,
): MoneyDto {
return {
currency: price.currency,
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
};
}
private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
} }
} }
@@ -44,15 +44,9 @@ export class ForecastRepository {
data: { membershipRevision: { increment: 0 } }, data: { membershipRevision: { increment: 0 } },
}); });
if (!current.count) return null; if (!current.count) return null;
} } else if (!(await tx.tbankConnection.findFirst({
const connection = await tx.tbankConnection.findFirst({ where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
where: { }))) return null;
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
});
if (!connection) return null;
const key = { ...owner, scopeKey: input.scopeKey }; const key = { ...owner, scopeKey: input.scopeKey };
const previous = await tx.tbankForecast.findUnique({ const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key }, where: { userId_revision_scopeKey: key },
@@ -52,7 +52,7 @@ export class PortfolioRepository {
revision: string, revision: string,
accountId: string, accountId: string,
processRunId: string, processRunId: string,
active: ReadonlySet<string>, pending: ReadonlyMap<string, Promise<void>>,
force: boolean, force: boolean,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
@@ -83,7 +83,7 @@ export class PortfolioRepository {
if ( if (
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
active.has(previous.attemptId) pending.has(previous.attemptId)
) { ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
@@ -1,16 +1,110 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '../../../../common/money.types';
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
import { import {
add, add,
decimalFromString,
formatDecimal, formatDecimal,
isZero, isZero,
multiply, multiply,
subtract,
} from '../../../../common/decimal'; } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '../../../../integrations/tbank/decimal';
import { import {
metadataKey,
type TbankInstrument,
type TbankMoney,
TbankPortfolio, TbankPortfolio,
TbankPositions, TbankPositions,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { portfolioStructure } from '../domain/portfolio-structure';
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
function mapPortfolio(
upstream: NonNullable<TbankPortfolio['positions']>,
cash: TbankMoney[],
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
lastSuccessfulAt: string,
): PortfolioDto {
const positions = upstream
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
.filter((position): position is PortfolioPositionDto => position !== null);
const balances = new Map<string, ReturnType<typeof decimal>>();
for (const value of cash) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...balances].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, cash),
positions,
cashBalances,
structure: portfolioStructure(positions, cashBalances),
};
}
function mapPosition(
position: NonNullable<TbankPortfolio['positions']>[number],
metadata: null | TbankInstrument,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = money(position.currentPrice);
const averagePurchasePrice = money(position.averagePositionPrice);
const currentValue = currentPrice && {
currency: currentPrice.currency,
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
};
const absoluteReturn =
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
? {
currency: currentPrice.currency,
value: formatDecimal(multiply(
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
quantity,
)),
}
: null;
return {
figi,
instrumentUid,
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
function money(value: TbankMoney | undefined): Money | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
function totalsByCurrency( function totalsByCurrency(
positions: NonNullable<TbankPortfolio['positions']>, positions: NonNullable<TbankPortfolio['positions']>,
@@ -44,4 +138,4 @@ function totalsByCurrency(
.map(([currency, value]) => ({ currency, value: formatDecimal(value) })); .map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
} }
export { totalsByCurrency }; export { mapPortfolio, totalsByCurrency };
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { ForecastService } from './application/forecast.service'; import { ForecastService } from './application/forecast.service';
import { PortfolioService } from './application/portfolio.service'; import { PortfolioService } from './application/portfolio.service';
import { ForecastRepository } from './infrastructure/forecast.repository'; import { ForecastRepository } from './infrastructure/forecast.repository';
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
import { PortfolioController } from './presentation/portfolio.controller'; import { PortfolioController } from './presentation/portfolio.controller';
@Module({ @Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule], imports: [AuthModule, TbankModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
PortfolioService, PortfolioService,
@@ -22,7 +22,6 @@ import {
} from 'class-validator'; } from 'class-validator';
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { operationCategories } from '../operations/operations.api'; import { operationCategories } from '../operations/operations.api';
const MIN_ACCOUNT_ID_LENGTH = 1; const MIN_ACCOUNT_ID_LENGTH = 1;
@@ -216,11 +215,6 @@ export class TbankSyncAcceptedDto {
@ApiProperty({ format: 'date-time' }) declare now: string; @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 { export class TbankSyncStatusDto {
@ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
@@ -232,10 +226,4 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode: @ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string; 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 { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.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 { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { InstrumentsModule } from '../instruments/instruments.module';
import { OperationsModule } from '../operations/operations.module'; import { OperationsModule } from '../operations/operations.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankSyncController } from './tbank-sync.controller'; import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncRepository } from './tbank-sync.repository'; import { TbankSyncRepository } from './tbank-sync.repository';
import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncService } from './tbank-sync.service';
@@ -19,10 +15,6 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule, PortfolioModule,
OperationsModule, OperationsModule,
InstrumentSourcesModule, InstrumentSourcesModule,
InstrumentsModule,
BrokerageAccountsModule,
IncomeModule,
UpcomingEventsModule,
], ],
controllers: [TbankSyncController], controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository], providers: [TbankSyncService, TbankSyncRepository],
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common'; 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 { Prisma } from '../../../../generated/prisma/client';
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
@@ -27,7 +27,7 @@ export class TbankSyncRepository {
data: { membershipRevision: { increment: 0 } }, data: { membershipRevision: { increment: 0 } },
}); });
if (!valid.count) return null; 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 key = { userId, revision, scopeKey };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: key }, where: { userId_revision_scopeKey: key },
@@ -6,8 +6,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import { import {
@@ -16,26 +14,17 @@ import {
TbankMembershipService, TbankMembershipService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api'; } 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 { import {
CandleService, CandleService,
CatalogDiscoveryService, CatalogDiscoveryService,
CatalogEnrichmentService, CatalogEnrichmentService,
} from '../instruments/instruments.api'; } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { planInvestmentScreen } from '../investment-sync-plan'; import { planInvestmentScreen } from '../investment-sync-plan';
import { import {
BrokerageOperationsService,
BrokerageOperationsSyncService, BrokerageOperationsSyncService,
type OperationInterval, type OperationInterval,
} from '../operations/operations.api'; } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
import { normalizeScreen } from './tbank-screen'; import { normalizeScreen } from './tbank-screen';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import { import {
@@ -44,9 +33,6 @@ import {
} from './tbank-sync.repository'; } from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0; const FOREGROUND_CANDLE_PRIORITY = 0;
const EXCLUSIVE_END_OFFSET_MS = 1;
const PENDING_CHILD_COUNT = 0;
const COMPLETED_CHILD_COUNT = 1;
@Injectable() @Injectable()
export class TbankSyncService { export class TbankSyncService {
@@ -59,16 +45,10 @@ export class TbankSyncService {
private readonly membership: TbankMembershipService, private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService, private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService, private readonly operations: BrokerageOperationsSyncService,
private readonly ledger: BrokerageOperationsService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService, private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService, 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) { async request(userId: string, input: InvestmentScreenDto) {
@@ -368,7 +348,6 @@ export class TbankSyncService {
: children.some((child) => child.state === 'cancelled') : children.some((child) => child.state === 'cancelled')
? 'cancelled' ? 'cancelled'
: 'succeeded'; : 'succeeded';
const resources = await this.observe(userId, intent.scope);
if ( if (
(await this.access.local(userId, intent.scope.accountId)).revision !== (await this.access.local(userId, intent.scope.accountId)).revision !==
connection.revision connection.revision
@@ -380,12 +359,6 @@ export class TbankSyncService {
...intent, ...intent,
state, state,
errorCode: failed, 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, userId: string,
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'], work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> { ): 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:') const observed = work.scopeKey.startsWith('operations:')
? await this.operations.observe(userId, work.id) ? await this.operations.observe(userId, work.id)
: work; : work;
@@ -515,112 +388,6 @@ export class TbankSyncService {
now: work.createdAt.toISOString(), now: work.createdAt.toISOString(),
state, state,
errorCode: observed.errorCode, 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 { decimalFromString } from '../../../common/decimal';
import { bondEvent, dividendEvent } from './upcoming-events.service'; import { bondEvent, dividendEvent } from './upcoming-event.mapper';
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
@@ -1,22 +1,17 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import { import {
add, add,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
isZero, isZero,
multiply,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { decimal } from '../../../integrations/tbank/decimal';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
metadataKey, metadataKey,
TbankAccessService, TbankAccessService,
type TbankDividend,
} from '../../../integrations/tbank/tbank.api'; } from '../../../integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { CatalogEnrichmentService } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -25,6 +20,7 @@ import {
type PortfolioPositionDto, type PortfolioPositionDto,
PortfolioService, PortfolioService,
} from '../portfolio/portfolio.api'; } from '../portfolio/portfolio.api';
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
import { import {
UpcomingEventDto, UpcomingEventDto,
UpcomingEventsDto, 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 { function supported(position: Position): boolean {
return ( return (
!isZero(decimalFromString(position.quantity)) && !isZero(decimalFromString(position.quantity)) &&
@@ -274,21 +225,8 @@ function dateWindow(query: UpcomingEventsQueryDto) {
return { from, to }; 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[] { 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) { for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
continue; continue;
+27 -17
View File
@@ -1,12 +1,13 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { PinoLogger } from 'nestjs-pino'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { AppModule } from '../src/app.module'; 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 { configureApp } from '../src/main';
import { AuthService } from '../src/modules/auth/application/auth.service'; import { AuthService } from '../src/modules/auth/application/auth.service';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
@@ -16,10 +17,22 @@ type Problem = { code: string };
describe('Authentication (e2e)', () => { describe('Authentication (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
const logs: Record<string, unknown>[] = [];
beforeEach(async () => { beforeEach(async () => {
logs.length = 0;
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], 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(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
@@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => {
]); ]);
const winner = responses.find((response) => response.status === 200); const winner = responses.find((response) => response.status === 200);
if (!winner) throw new Error('Expected one successful rotation'); if (!winner) throw new Error('Expected one successful rotation');
expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/v1/auth/refresh') .post('/api/v1/auth/refresh')
.set('Cookie', cookie) .set('Cookie', cookie)
.expect(401); .expect(401)
.expect((response) => expect(response.headers['set-cookie']).toBeUndefined());
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/v1/auth/refresh') .post('/api/v1/auth/refresh')
.set('Cookie', winner.headers['set-cookie'][0]) .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 () => { 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()) const response = await request(app.getHttpServer())
.get('/api/v1/auth/me') .get('/api/v1/auth/me')
.set('Origin', 'http://localhost:5173') .set('Origin', 'http://localhost:5173')
@@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => {
expect(response.headers['access-control-expose-headers']).toContain( expect(response.headers['access-control-expose-headers']).toContain(
'X-Request-ID', 'X-Request-ID',
); );
expect(event).toHaveBeenCalledTimes(1); expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
expect(event).toHaveBeenCalledWith( expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
'warn', ]);
'http.request.completed',
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
);
}); });
it('logs an unhandled error with safe public context and correlation', async () => { 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'; const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
jest jest
.spyOn(app.get(AuthService), 'login') .spyOn(app.get(AuthService), 'login')
@@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => {
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
); );
expect(error).toHaveBeenCalledTimes(1); const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
expect(error).toHaveBeenCalledWith( expect(completed).toEqual([
expect.objectContaining({ expect.objectContaining({
requestId: response.headers['x-request-id'], requestId: response.headers['x-request-id'],
clientRequestId, clientRequestId,
status: 500, status: 500,
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.completed',
}), }),
'http.request.completed', ]);
); expect(JSON.stringify(logs)).not.toContain(
expect(JSON.stringify(error.mock.calls)).not.toContain(
'SYNTHETIC_AUDIT_SECRET', 'SYNTHETIC_AUDIT_SECRET',
); );
}); });
+11
View File
@@ -188,6 +188,17 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
).toBe(0); ).toBe(0);
expect((await summary('2026-08', card.id)).net.value).toBe('0'); expect((await summary('2026-08', card.id)).net.value).toBe('0');
}); });
it('retains omitted metadata on a name-only patch and clears explicit nulls', async () => {
const card = await createCard('0', owner, { bankName: 'Bank', lastFour: '1234' });
const renamed = (await auth('patch', '/' + card.id)
.send({ version: card.version, name: 'Renamed' }).expect(200)).body as BankCardDto;
expect(renamed).toMatchObject({ name: 'Renamed', bankName: 'Bank', lastFour: '1234' });
const cleared = (await auth('patch', '/' + card.id)
.send({ version: renamed.version, bankName: null, lastFour: null }).expect(200)).body as BankCardDto;
expect(cleared).toMatchObject({ name: 'Renamed', bankName: null, lastFour: null });
expect(cleared.version).toBe(renamed.version + 1);
});
it('rejects explicit null on nonnullable write fields without changing values or versions', async () => { it('rejects explicit null on nonnullable write fields without changing values or versions', async () => {
const card = await createCard(); const card = await createCard();
const expense = await createOp(op(card.id)); const expense = await createOp(op(card.id));
+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. // Single-use codes must be caught too; fixture literals remain independent wire assertions.
test('machine codes require enum members without banning ordinary text or Prisma selects', async () => { test('machine codes require enum members without banning ordinary text or Prisma selects', async () => {
const eslint = new ESLint(); const eslint = new ESLint();
+3 -1
View File
@@ -7,12 +7,13 @@ const cwd = resolve(__dirname, '..');
const available = readdirSync(__dirname) const available = readdirSync(__dirname)
.filter((name) => name.endsWith('.e2e-spec.ts')) .filter((name) => name.endsWith('.e2e-spec.ts'))
.sort(); .sort();
const performanceSuites = new Set(['tbank-read-baseline.e2e-spec.ts']);
const requested = process.argv.slice(2); const requested = process.argv.slice(2);
const suites = const suites =
requested.length === 0 requested.length === 0
? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts'] ? ['app.e2e-spec.ts', 'bank-cards.e2e-spec.ts']
: requested.length === 1 && requested[0] === '--all' : requested.length === 1 && requested[0] === '--all'
? available ? available.filter((suite) => !performanceSuites.has(suite))
: requested; : requested;
if (suites.some((suite) => !available.includes(suite))) { if (suites.some((suite) => !available.includes(suite))) {
throw new Error('Unknown e2e suite'); throw new Error('Unknown e2e suite');
@@ -63,6 +64,7 @@ function run(args, env) {
WL_E2E_DIRECTORY: dir, WL_E2E_DIRECTORY: dir,
NODE_ENV: 'development', NODE_ENV: 'development',
NODE_OPTIONS: '--experimental-vm-modules', NODE_OPTIONS: '--experimental-vm-modules',
LOG_LEVEL: 'error',
}; };
console.log(`SQLite ${suite}: ${dir}`); console.log(`SQLite ${suite}: ${dir}`);
try { try {
+10 -43
View File
@@ -41,7 +41,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
lastSuccessfulAt: null, lastSuccessfulAt: null,
resourceKey: 'detail/uid', resourceKey: 'detail/uid',
dataRevision: '1', dataRevision: '1',
stateVersion: 'detail-v1',
sync: { id: null, state: 'idle', errorCode: null }, sync: { id: null, state: 'idle', errorCode: null },
}, },
}, },
@@ -273,7 +272,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
).toBe(abandoned?.work.attemptId); ).toBe(abandoned?.work.attemptId);
await reopened.read('owner', input); await reopened.read('owner', input);
await settled(); await settled();
expect(upstream.getCandles.mock.calls.length).toBeGreaterThan(1); expect(upstream.getCandles).toHaveBeenCalledTimes(1);
expect( expect(
(await repository.work('owner', 'revision', 'candles:uid:month')) (await repository.work('owner', 'revision', 'candles:uid:month'))
?.attemptId, ?.attemptId,
@@ -483,52 +482,20 @@ describe('atomic shared candle reconciliation (SQLite)', () => {
await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }), await prisma.tbankCandleCoverage.count({ where: { isComplete: false } }),
).toBe(1); ).toBe(1);
}); });
it('prefetches only current supported portfolio instruments and lets screen work run between history windows', async () => { it('loads only the requested period and reuses its saved coverage', async () => {
await prisma.tbankInstrument.create({
data: {
provider: 'tbank',
uid: 'opened',
instrumentType: 'share',
metadata: {},
fetchedAt: new Date(),
},
});
const service = createService(); const service = createService();
let release: (value: unknown[]) => void = () => {}; const input = {
const blocked = new Promise<unknown[]>((resolve) => { uid: 'uid',
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',
instrumentType: 'share', instrumentType: 'share',
period: 'month', period: 'month',
}); } as const;
release([]); await service.read('owner', input);
await settled(); await settled();
expect((upstream.getCandles.mock.calls as unknown[][])[1]?.[1]).toBe( expect(upstream.getCandles).toHaveBeenCalledTimes(1);
'opened', expect(await repository.work('owner', 'revision', 'candles:uid:all')).toBeNull();
);
expect(
new Set(upstream.getCandles.mock.calls.map((call: unknown[]) => call[1])),
).toEqual(new Set(['opened', 'uid']));
const count = upstream.getCandles.mock.calls.length; const count = upstream.getCandles.mock.calls.length;
await service.prefetch('owner', 'revision', [ await service.read('owner', input);
{ instrumentUid: 'uid', type: 'STOCK' }, await settled();
]);
expect(upstream.getCandles).toHaveBeenCalledTimes(count); 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(); 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 = () => const observe = () =>
request(app.getHttpServer()) request(app.getHttpServer())
.get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100') .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( expect(second.meta.coverage?.requested).not.toEqual(
first.meta.coverage?.requested, first.meta.coverage?.requested,
); );
expect(second.meta.stateVersion).toBe(first.meta.stateVersion); expect(second.meta.dataRevision).toBe(first.meta.dataRevision);
await prisma.tbankOperationCoverage.updateMany({ await prisma.tbankOperationCoverage.updateMany({
data: { data: {
to: new Date(Date.now() - 60_000), to: new Date(Date.now() - 60_000),
@@ -285,10 +285,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => {
}); });
const changed = (await observe().expect(200)) const changed = (await observe().expect(200))
.body as BrokerageOperationsResourceDto; .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(); 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)) const before = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto; .body as BrokerageOperationsResourceDto;
assert(before.data?.nextCursor); 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)) const after = (await get('limit=1&sync=observe').expect(200))
.body as BrokerageOperationsResourceDto; .body as BrokerageOperationsResourceDto;
expect(after.data?.items[0].logoName).toBe('saved.png'); 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); expect(after.meta.dataRevision).toBe(before.meta.dataRevision);
await get( await get(
`limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`, `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()); afterAll(async () => prisma.$disconnect());
it.each([[false, 1], [true, 1], [false, 2], [true, 2]] as const)(
'extends legacy intents in stable priority order (atomic=%s, previousPriority=%s)',
async (atomic, previousPriority) => {
const run = 'legacy-run';
const initial = await repository.request('owner', 'revision', [january, february], run, false, new Set(), previousPriority);
if (!initial) throw new Error('Missing work');
const key = (range: typeof january) => JSON.stringify([
range.accountId, range.from.toISOString(), range.to.toISOString(), '',
]);
const march = { ...january, from: february.to, to: new Date('2026-04-01Z') };
await prisma.tbankSyncWork.update({
where: { id: initial.work.id },
data: { intent: { atomic, intervals: [key(february), key(january)] } },
});
const extended = await repository.request(
'owner', 'revision', [january, march], run, false,
new Set([initial.work.attemptId]), 1,
);
expect(extended?.launch).toBe(false);
expect(extended?.work.id).toBe(initial.work.id);
expect(extended?.work.priority).toBe(1);
expect(extended?.work.intent).toEqual({
atomic,
atomicIntervals: atomic ? [key(february), key(january), key(march)] : [],
screenIntervals: previousPriority === 1
? [key(february), key(january), key(march)]
: [key(january), key(march)],
intervals: previousPriority === 1
? [key(february), key(january), key(march)]
: [key(january), key(march), key(february)],
});
},
);
it('joins overlapping account and instrument refresh into one atomic publication group', async () => { it('joins overlapping account and instrument refresh into one atomic publication group', async () => {
const gate = deferred<TbankOperationsByCursorResponse>(); const gate = deferred<TbankOperationsByCursorResponse>();
upstream.getOperationsByCursor upstream.getOperationsByCursor
-6
View File
@@ -134,12 +134,6 @@ describe('T-Bank persisted read budget', () => {
'bond', 'bond',
instruments.find((item) => item.uid === uid), 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[]) => getLastPrices: (_token: string, uids: string[]) =>
counted( counted(
'lastPrices', 'lastPrices',
+6 -10
View File
@@ -103,10 +103,11 @@ describe('screen sync HTTP and SQLite', () => {
release([]); release([]);
const done = await settled(ids[0]); const done = await settled(ids[0]);
expect(done.state).toBe('succeeded'); expect(done.state).toBe('succeeded');
expect(done.resources[0]).toMatchObject({ const accounts = await request(app.getHttpServer())
resourceKey: 'accounts', .get('/api/v1/brokerage-accounts?sync=observe')
availability: 'complete', .set('Authorization', auth())
}); .expect(200);
expect(accounts.body).toMatchObject({ data: [], meta: { availability: 'complete' } });
expect(JSON.stringify(done)).not.toMatch( expect(JSON.stringify(done)).not.toMatch(
/revision|attemptId|encryptedToken|synthetic/, /revision|attemptId|encryptedToken|synthetic/,
); );
@@ -297,20 +298,17 @@ describe('screen sync HTTP and SQLite', () => {
.set('Authorization', auth()) .set('Authorization', auth())
.expect(200); .expect(200);
const first = before.body as { const first = before.body as {
meta: { dataRevision: string; stateVersion: string }; meta: { dataRevision: string };
}; };
const last = after.body as { const last = after.body as {
data: { items: unknown[] }; data: { items: unknown[] };
meta: { meta: {
dataRevision: string; dataRevision: string;
stateVersion: string;
sync: { id: string }; sync: { id: string };
}; };
}; };
expect(last.data.items).toEqual([]); expect(last.data.items).toEqual([]);
expect(last.meta.dataRevision).toBe(first.meta.dataRevision); 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); await status(last.meta.sync.id).expect(200);
expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1);
}); });
@@ -389,7 +387,6 @@ describe('screen sync HTTP and SQLite', () => {
try { try {
expect((await status(second).expect(200)).body).toMatchObject({ expect((await status(second).expect(200)).body).toMatchObject({
state: 'running', state: 'running',
progress: { completed: 1, total: 2, discovering: false },
}); });
await status(second, 'other').expect(404); await status(second, 'other').expect(404);
} finally { } finally {
@@ -401,7 +398,6 @@ describe('screen sync HTTP and SQLite', () => {
expect(await settled(second)).toMatchObject({ expect(await settled(second)).toMatchObject({
state: outcome, state: outcome,
errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null, errorCode: outcome === 'failed' ? 'TBANK_UNAVAILABLE' : null,
progress: { completed: 2, total: 2, discovering: false },
}); });
await prisma.tbankConnection.delete({ where: { userId: 'owner' } }); await prisma.tbankConnection.delete({ where: { userId: 'owner' } });
await status(second).expect(404); await status(second).expect(404);