56 Commits
Author SHA1 Message Date
ksv741 3d50db7903 ci: validate release PR commits independently (WealthLedger/main#30)
Backend verify / commits (push) Skipped
Backend image / publish (push) Successful in 7m28s
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Successful in 5m3s
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m11s
2026-10-12 23:39:30 +03:00
ksv741 8d5569a3c7 chore(release): 2.1.0
Backend verify / commits (pull_request) Failing after 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
ksv741 90486d4237 Merge pull request 'fix: bound hourly step awaits by the per-step wait limit (#56)' (#78) from fix/56-hourly-step-await-bound into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m0s
Reviewed-on: #78
2026-10-12 22:25:27 +03:00
ksv741 52fa3a36ba fix: bound hourly step awaits by the per-step wait limit (#56)
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m45s
2026-10-12 22:17:53 +03:00
ksv741 b9b76bab91 Merge pull request 'fix: restore terminal hourly lifecycle after persistence failure (#56)' (#77) from fix/56-harden-hourly-sync-lifecycle into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m10s
Reviewed-on: #77
Reviewed-by: hermes-agent <hermes@ksv741.ru>
2026-10-12 21:59:27 +03:00
ksv741 fa57a201fd fix: restore terminal hourly lifecycle after persistence failure (#56)
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m48s
2026-10-12 20:42:19 +03:00
codex-bot 96a442253e Merge pull request 'chore: align backend engineering workflow (WealthLedger/main#27)' (#76) from chore/27-engineering-workflow into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m24s
2026-10-12 11:00:36 +03:00
ksv741 c5248dbefc docs: pin backend workflow reference (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m3s
2026-10-12 10:22:13 +03:00
ksv741 c7d060023b fix: align backend workflow rules (WealthLedger/main#27)
Backend verify / commits (pull_request) Canceled after 0s
Backend verify / security (pull_request) Canceled after 0s
Backend verify / verify (pull_request) Canceled after 0s
2026-10-12 10:20:50 +03:00
ksv741 a31e8d2c4d chore: align backend engineering workflow (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m56s
2026-10-12 09:20:31 +03:00
codex-bot 2f7c15205f Merge pull request 'docs: clarify backend verification policy' (#75) from codex/issue-24-process into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m9s
2026-10-11 23:23:10 +03:00
ksv741 d7895f287e docs: clarify backend verification policy
Backend verify / commits (pull_request) Successful in 34s
Backend verify / security (pull_request) Successful in 16s
Backend verify / verify (pull_request) Successful in 5m15s
2026-10-11 23:06:51 +03:00
codex-bot bcd5cb3dd0 feat: add stock screener
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m33s
2026-10-11 11:45:09 +03:00
ksv741 0ad84ff4f0 ci: skip checks on PR metadata edits
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m38s
2026-10-11 11:36:51 +03:00
ksv741 7d10ef8adf feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s
2026-10-11 11:18:21 +03:00
ksv741 bc631e72de test: align resource availability metadata
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m21s
2026-10-09 23:10:50 +03:00
ksv741 947ffefa9b fix: keep candle retries read-only 2026-10-09 23:10:50 +03:00
ksv741 db375cbee7 test: align all accounts metadata 2026-10-09 23:10:50 +03:00
ksv741 a46a4112cb chore: prune obsolete eslint suppressions 2026-10-09 23:10:50 +03:00
ksv741 966b2a5836 test: align upcoming events with local reads 2026-10-09 23:10:50 +03:00
ksv741 323b83b582 feat: reconcile hourly investment resource states 2026-10-09 23:10:50 +03:00
codex-bot 5d857de7a5 Merge pull request 'feat: add hourly portfolio sync history' (#49) from release/2.0.0 into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m46s
Merge hourly portfolio sync history backend when checks pass
2026-10-09 09:57:20 +03:00
ksv741 2facd8675c test: increase ledger request timeout
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 5m21s
2026-10-09 09:50:59 +03:00
ksv741 4870d49503 test: extend ledger sqlite timeout
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Failing after 4m32s
2026-10-09 09:43:41 +03:00
ksv741 85858a160f fix: unblock backend dependency audit
Backend verify / commits (pull_request) Successful in 36s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Failing after 4m47s
2026-10-09 09:14:28 +03:00
ksv741 0057925888 feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
2026-10-09 09:04:41 +03:00
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 e76eb545d7 perf: group catalog source reads by identity
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m48s
2026-09-28 16:23:58 +03:00
ksv741 7d2262fb43 fix: resolve build imports before linting
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 11m25s
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 15:48:44 +03:00
ksv741 33a5135741 chore: merge develop into eslint branch
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m54s
2026-09-28 15:19:14 +03:00
ksv741 53b2794963 ci: reduce duplicate actions and serialize checks
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m28s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 3m32s
2026-09-28 14:12:38 +03:00
ksv741 2d14081d27 refactor: enforce enum machine codes
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
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 13:18:06 +03:00
ksv741 0303e60b64 style: align multiline ternary operators
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m11s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 34s
2026-09-28 12:33:56 +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
166 changed files with 6474 additions and 4537 deletions
+17 -8
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#источники).
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные `modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
публичные входы для protobuf conversion и interval rules: импорт расчётов публичные входы для protobuf conversion, protocol/error vocabulary и interval
не загружает DI/Prisma. rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты. Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks. Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются Новый export требует текущего потребителя; controller и persistence остаются
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
Исключения для индексов, default values, enum/type declarations и readonly Исключения для индексов, default values, enum/type declarations и readonly
properties заданы в ESLint config. Test fixtures/expected values остаются inline, properties заданы в ESLint config. Test fixtures/expected values остаются inline,
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы; остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента. Machine codes и protocol values задаются строковыми enum у владельца.
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
## Logging ## Logging
@@ -261,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
## Проверка ## Проверка
`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.
+31 -13
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную. Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit. обычные labels не выноси в constants без semantic benefit.
@@ -56,19 +65,28 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
содержит одну связанную задачу. Root другого checkout не управляет этим repo. `spec:`. Каждый commit содержит одну задачу. Исключения release и правила
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+7 -1
View File
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
Преобразование provider records происходит на boundary: например, Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
не должны загружать DI/Prisma через service exports. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов. на границе клиента/его mapping, а не становятся моделью чистых расчётов.
@@ -41,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-и-сохранность-данных).
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
-31
View File
@@ -1,31 +0,0 @@
name: Backend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+68 -1
View File
@@ -2,11 +2,78 @@ name: Backend verify
on: on:
push: push:
branches: ['**'] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '47 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+109
View File
@@ -1,5 +1,114 @@
# Changelog # Changelog
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [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
+40 -10
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
@@ -74,17 +77,38 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
### CI и обновления зависимостей ### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client, Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client,
`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.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push, значениями, блокирует любой runtime advisory через
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические `npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter. совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются. Новые коммиты этими исключениями не покрываются.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Backend verify / commits (pull_request)`,
`Backend verify / security (pull_request)` и
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
Renovate группирует minor/patch updates, а major требует согласования в Renovate группирует minor/patch updates, а major требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install, фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
@@ -133,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`
@@ -267,9 +295,11 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+9 -1
View File
@@ -1,4 +1,12 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] }, defaultIgnores: false,
ignores: [
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
'references-empty': [2, 'never'],
},
}; };
+2 -33
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
@@ -37,11 +32,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/income/income-analytics.service.ts": { "src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -54,7 +44,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
@@ -72,9 +62,6 @@
} }
}, },
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
} }
@@ -107,11 +94,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/investment-resource.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 3
}
},
"src/modules/investments/investment-sync-plan.ts": { "src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -154,14 +136,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": { "src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -169,17 +143,12 @@
}, },
"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
} }
}, },
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
+109 -1
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/**']),
@@ -48,7 +51,11 @@ export default defineConfig(
'perfectionist/sort-classes': 'off', 'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }], '@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'], '@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'after'], '@stylistic/operator-linebreak': [
'error',
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
@@ -56,6 +63,18 @@ export default defineConfig(
files: ['**/*.mjs'], files: ['**/*.mjs'],
extends: [node.configs['flat/recommended-module']], extends: [node.configs['flat/recommended-module']],
}, },
{
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
rules: {
// These scripts run after build; lint validates their source paths before dist exists.
'n/no-missing-require': ['error', {
resolverConfig: {
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
extensionAlias: { '.js': ['.ts', '.js'] },
},
}],
},
},
{ {
files: ['**/*.ts'], files: ['**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript], extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
@@ -103,4 +122,93 @@ 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: [
'src/**/*.ts',
],
ignores: [
'**/*.spec.ts',
'test/**/*.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
message: 'Use the owning enum member for machine codes and protocol values.',
},
{
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
message: 'Use the owning enum member instead of a template machine code.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
message: 'Use an enum member instead of an inline code or protocol value.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
message: 'Use an enum member instead of constructing a code or protocol value.',
},
{
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
message: 'Use a computed enum member as the protocol mapping key.',
},
{
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
message: 'Compare codes with their enum members, not string literals.',
},
{
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
},
); );
+61 -226
View File
@@ -1,15 +1,15 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "0.2.3", "version": "2.1.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.1.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",
@@ -2797,29 +2774,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 +5049,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",
@@ -6194,9 +6125,9 @@
} }
}, },
"node_modules/babel-plugin-istanbul": { "node_modules/babel-plugin-istanbul": {
"version": "8.0.0", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.2.tgz",
"integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "integrity": "sha512-bXWZeWQvvCZcD2uHLWVbitr4s1+YqILBbWB/szXKtHXt6crLlD6mqxHZ0qWy2X/Xx5RtQvqqW80JHAXMlp+y1Q==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"workspaces": [ "workspaces": [
@@ -6270,6 +6201,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 +6245,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 +6356,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 +6460,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 +6974,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 +8314,6 @@
} }
} }
}, },
"node_modules/eslint-plugin-import-x/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-import-x/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint-plugin-import-x/node_modules/minimatch": { "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 +8520,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 +8568,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 +8940,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 +9424,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",
@@ -9653,13 +9517,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/handlebars": { "node_modules/handlebars": {
"version": "4.7.9", "version": "4.7.10",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.5", "minimist": "^1.2.8",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"source-map": "^0.6.1", "source-map": "^0.6.1",
"wordwrap": "^1.0.0" "wordwrap": "^1.0.0"
@@ -9967,9 +9831,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 +11505,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 +12692,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 +12702,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 +13080,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",
@@ -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.1.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"
} }
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "TbankPortfolioValueHistory" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"deletedAt" DATETIME,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("userId", "accountId"),
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"currency" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateIndex
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
+29
View File
@@ -17,6 +17,7 @@ model User {
bankCardOperations BankCardOperation[] bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -69,6 +70,34 @@ model TbankPortfolio {
@@id([userId, revision, accountId]) @@id([userId, revision, accountId])
} }
// Account valuation history survives connection replacement and stays hidden
// until the current connection confirms the account again.
model TbankPortfolioValueHistory {
userId String
accountId String
deletedAt DateTime?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
points TbankPortfolioValuePoint[]
@@id([userId, accountId])
}
// One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
currency String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt])
}
model TbankOperation { model TbankOperation {
userId String userId String
revision String revision String
-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,
@@ -7,11 +7,13 @@ import {
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
declare status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string; declare code: string;
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array. // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
+8
View File
@@ -0,0 +1,8 @@
export enum HttpErrorCode {
BadRequest = 'BAD_REQUEST',
InternalServerError = 'INTERNAL_SERVER_ERROR',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
ValidationError = 'VALIDATION_ERROR',
}
+18 -16
View File
@@ -9,24 +9,26 @@ import {
ValidationError, ValidationError,
} from '@nestjs/common'; } from '@nestjs/common';
import { HttpErrorCode } from '../error-code';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
const status = const status =
exception instanceof HttpException ? exception instanceof HttpException
exception.getStatus() : ? exception.getStatus()
HttpStatus.INTERNAL_SERVER_ERROR; : HttpStatus.INTERNAL_SERVER_ERROR;
const payload = const payload =
exception instanceof HttpException ? exception.getResponse() : undefined; exception instanceof HttpException ? exception.getResponse() : undefined;
const isValidation = Array.isArray( const isValidation = Array.isArray(
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation ? const code = isValidation
'VALIDATION_ERROR' : ? HttpErrorCode.ValidationError
this.codeFor(status, payload); : this.codeFor(status, payload);
response.locals ??= {}; response.locals ??= {};
response.locals.publicErrorCode = code; response.locals.publicErrorCode = code;
response response
@@ -35,13 +37,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
.json({ .json({
status, status,
code, code,
...(isValidation ? ...(isValidation
{ ? {
errors: this.validationErrors( errors: this.validationErrors(
(payload as { message: ValidationError[] }).message, (payload as { message: ValidationError[] }).message,
), ),
} : }
{}), : {}),
}); });
} }
@@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
return ( return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request. // eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{ {
400: 'BAD_REQUEST', 400: HttpErrorCode.BadRequest,
401: 'UNAUTHORIZED', 401: HttpErrorCode.Unauthorized,
404: 'NOT_FOUND', 404: HttpErrorCode.NotFound,
415: 'UNSUPPORTED_MEDIA_TYPE', 415: HttpErrorCode.UnsupportedMediaType,
500: 'INTERNAL_SERVER_ERROR', 500: HttpErrorCode.InternalServerError,
}[status] ?? 'INTERNAL_SERVER_ERROR' }[status] ?? HttpErrorCode.InternalServerError
); );
} }
+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,10 +11,10 @@ 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', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
requestId: 'request', asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -32,8 +31,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,12 +1,11 @@
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>;
const allowedFields = new Set([ const allowedFields = new Set([
'asOf',
'attempts', 'attempts',
'brokerageAccountId', 'brokerageAccountId',
'clientRequestId', 'clientRequestId',
@@ -27,10 +26,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 +47,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,
); );
} }
+3 -3
View File
@@ -105,9 +105,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
JWT_SECRET: jwtSecret, JWT_SECRET: jwtSecret,
PORT: parsedPort, PORT: parsedPort,
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey, TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ? ...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
{ TBANK_CA_CERT_PATH: tbankCaCertPath } : ? { TBANK_CA_CERT_PATH: tbankCaCertPath }
{}), : {}),
TBANK_GRPC_ENDPOINT: tbankEndpoint, TBANK_GRPC_ENDPOINT: tbankEndpoint,
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit, TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'], LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
+7 -5
View File
@@ -1,6 +1,8 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2'; import { constants as httpStatus } from 'node:http2';
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0; const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1; const EXPECTED_CURSOR_ROWS = 1;
@@ -152,7 +154,7 @@ export class MoexClient {
pageSize === EMPTY_PAGE_SIZE || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start) current.length !== Math.min(pageSize, total - start)
) { ) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
if (index + pageSize >= total) return rows; if (index + pageSize >= total) return rows;
start = index + pageSize; start = index + pageSize;
@@ -172,22 +174,22 @@ export class MoexClient {
} }
return body as IssResponse; return body as IssResponse;
} catch { } catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
} }
} }
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] { function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) { if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
const columns = block.columns; const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) { if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
return block.data.map((values) => { return block.data.map((values) => {
if (!Array.isArray(values) || values.length !== columns.length) { if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
return Object.fromEntries( return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array. // eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
+3 -1
View File
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1; const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2; const RATING_COLUMN = 2;
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
if (this.cache.get('registry') === pending) { if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 }); this.cache.set('registry', pending, { ttl: 60_000 });
} }
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
}); });
this.cache.set('registry', pending); this.cache.set('registry', pending);
} }
@@ -11,7 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { tbankSourceTtlMs } from './tbank-source.policy'; import { TbankErrorCode } from './tbank-error-code';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -43,12 +43,12 @@ export class InstrumentMetadataRepository {
async readMany(identities: MetadataIdentity[]) { async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [ const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim() ? ...(identity.instrumentUid?.trim()
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] : ? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
[]), : []),
...(identity.figi?.trim() ? ...(identity.figi?.trim()
[{ identifierType: 'figi', identifier: identity.figi.trim() }] : ? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
[]), : []),
]); ]);
if (!identifiers.length) { if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>(); return new Map<string, ReturnType<typeof selectMetadata>>();
@@ -233,9 +233,9 @@ export class InstrumentMetadataRepository {
update: { update: {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) ? increment: isDeepStrictEqual(previous?.metadata, payload)
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -256,9 +256,9 @@ export class InstrumentMetadataRepository {
matchedUids, matchedUids,
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ? increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -276,7 +276,7 @@ export class InstrumentMetadataRepository {
}, },
data: { data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE', errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
}); });
if (success) { if (success) {
@@ -286,7 +286,7 @@ export class InstrumentMetadataRepository {
state: 'running', state: 'running',
connection: { invalidToken: true }, connection: { invalidToken: true },
}, },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
} }
} }
@@ -319,11 +319,7 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
@@ -12,6 +12,7 @@ import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client'; import { TbankClient, type TbankInstrument } from './tbank.client';
export { metadataKey } from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository';
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
if ( if (
!identity.figi || !identity.figi ||
(error instanceof Error && (error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN') isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) { ) {
throw error; throw error;
} }
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
} else { } else {
this.logger.event('warn', 'instrument-metadata.failed', { this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} }
} }
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
} }
} catch { } catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', { this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
this.active.clear(); this.active.clear();
this.producers.clear(); this.producers.clear();
+40 -4
View File
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy'; import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
@@ -103,7 +104,7 @@ export class TbankAccessService {
state: 'running', state: 'running',
connection: { invalidToken: false }, connection: { invalidToken: false },
}, },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
}); });
return result.count === EXPECTED_UPDATED_CONNECTIONS; return result.count === EXPECTED_UPDATED_CONNECTIONS;
} }
@@ -120,6 +121,30 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
async onlineToken(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return this.tokens.decrypt(connection.encryptedToken);
}
async current(userId: string): Promise<{ revision: string; token: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -191,6 +216,17 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
// Value history visibility follows the confirmed membership only; a
// failed fetch never reaches this transaction and changes nothing.
const supportedIds = supported.map(({ id }) => id);
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: supportedIds } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
data: { deletedAt: fetchedAt },
});
if (completeWork) { if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
@@ -205,7 +241,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' }, where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
if (!claimed.count) return false; if (!claimed.count) return false;
await tx.tbankConnection.update({ await tx.tbankConnection.update({
@@ -236,10 +272,10 @@ export class TbankAccessService {
}, },
}); });
if (!connection) { if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); throw new ConflictException({ code: TbankErrorCode.NotConnected });
} }
if (connection.invalidToken) { if (connection.invalidToken) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
} }
if ( if (
connection.membershipAt && connection.membershipAt &&
@@ -1,14 +1,15 @@
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccountType } from './tbank-protocol';
import { TbankAccount } from './tbank.client'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: { [TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT', type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС', name: 'ИИС',
}, },
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: { [TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND', type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка', name: 'Фонд денежного рынка',
}, },
@@ -0,0 +1,10 @@
export enum TbankErrorCode {
InvalidToken = 'INVALID_TBANK_TOKEN',
NotConnected = 'TBANK_NOT_CONNECTED',
Unavailable = 'TBANK_UNAVAILABLE',
}
// Error messages and persisted codes cross an open string boundary.
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
return value === expected;
}
@@ -8,7 +8,9 @@ import type {
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
apiTradeAvailableFlag?: boolean; apiTradeAvailableFlag?: boolean;
assetUid?: string; assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string; classCode?: string;
countryOfRisk?: string;
currency?: string; currency?: string;
figi?: string; figi?: string;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
@@ -6,6 +6,7 @@ import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@Injectable() @Injectable()
@@ -37,7 +38,7 @@ export class TbankMembershipService {
.catch(() => .catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', { this.logger.event('error', 'tbank.membership.persistence_failed', {
userId, userId,
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}), }),
) )
.finally(() => this.active.delete(attemptId)); .finally(() => this.active.delete(attemptId));
@@ -62,7 +63,7 @@ export class TbankMembershipService {
} catch (error) { } catch (error) {
await this.access.failMembership( await this.access.failMembership(
attempt, attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
); );
} }
} }
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+74
View File
@@ -0,0 +1,74 @@
// String wire values for the protocol subset used by this integration.
// proto-loader decodes enums as strings; vendor contracts remain the source.
export enum InstrumentIdType {
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
Uid = 'INSTRUMENT_ID_TYPE_UID',
}
export enum TbankInstrumentStatus {
All = 'INSTRUMENT_STATUS_ALL',
}
export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE',
}
export enum TbankAccountStatus {
Open = 'ACCOUNT_STATUS_OPEN',
}
export enum TbankAccountType {
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
}
export enum TbankCandleInterval {
Day = 'CANDLE_INTERVAL_DAY',
}
export enum TbankOperationState {
Executed = 'OPERATION_STATE_EXECUTED',
}
export enum TbankCouponType {
Floating = 'COUPON_TYPE_FLOATING',
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
}
export enum TbankOperationType {
BondTax = 'OPERATION_TYPE_BOND_TAX',
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
Buy = 'OPERATION_TYPE_BUY',
BuyCard = 'OPERATION_TYPE_BUY_CARD',
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
Coupon = 'OPERATION_TYPE_COUPON',
DivExt = 'OPERATION_TYPE_DIV_EXT',
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
Input = 'OPERATION_TYPE_INPUT',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
Output = 'OPERATION_TYPE_OUTPUT',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
Tax = 'OPERATION_TYPE_TAX',
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
}
// Provider responses may contain values newer than our supported protocol subset.
export function isTbankProtocolValue(
value: unknown,
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
): boolean {
return value === expected;
}
@@ -3,7 +3,7 @@ import {
tbankSourceTtlMs, tbankSourceTtlMs,
} from './tbank-source.policy'; } from './tbank-source.policy';
describe('request-driven source freshness', () => { describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])( it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
@@ -1,15 +1,10 @@
// Freshness never establishes completeness. Immutable ranges are planned by // Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots. // their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = { export const tbankSourceTtlMs = {
membership: 300_000, membership: 3_600_000,
portfolio: 60_000, portfolio: 3_600_000,
quote: 60_000, quote: 3_600_000,
candleTail: 60_000, candleTail: 3_600_000,
operationsTail: 300_000,
metadata: 86_400_000,
catalog: 86_400_000,
company: 86_400_000,
calendar: 86_400_000,
} as const; } as const;
export function tbankSourceNeedsRefresh( export function tbankSourceNeedsRefresh(
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
@@ -26,8 +27,8 @@ export class TbankTokenService {
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
} }
throw error; throw error;
} }
+152 -49
View File
@@ -1,6 +1,7 @@
import { status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' }, to: { seconds: '2' },
cursor: 'cursor', cursor: 'cursor',
limit: 10, limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const, state: TbankOperationState.Executed,
}; };
it('passes a cursor operations request to OperationsService', async () => { it('passes a cursor operations request to OperationsService', async () => {
@@ -175,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 () => {
@@ -222,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);
}); });
@@ -317,3 +282,141 @@ describe('TbankClient metadata retries', () => {
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
}); });
describe('TbankClient share catalog', () => {
const token = 'token';
const catalogSize = 40;
let client: TbankClient;
let call: jest.Mock;
let logger: {
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
};
beforeEach(() => {
call = jest.fn();
logger = {
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
};
client = Object.assign(
Object.create(TbankClient.prototype) as TbankClient,
{ call, logger },
);
});
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
call.mockResolvedValue({ instruments });
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
});
});
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
const catalog = Array.from({ length: catalogSize }, (_, index) => {
const fitting = index === catalogSize - 1;
return {
apiTradeAvailableFlag: fitting,
buyAvailableFlag: fitting,
ticker: fitting ? 'FIT' : `T${index}`,
uid: fitting ? 'uid-fit' : `uid-${index}`,
};
});
call.mockResolvedValue({ instruments: catalog });
const shares = await client.getAllShares(token);
expect(shares).toEqual(catalog);
expect(shares).toHaveLength(catalogSize);
expect(shares).toContainEqual({
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
ticker: 'FIT',
uid: 'uid-fit',
});
});
it('rejects when the source refuses instead of returning an empty success', async () => {
const error = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
call.mockRejectedValue(error);
await expect(client.getAllShares(token)).rejects.toBe(error);
});
it('rejects when the response carries no instrument list', async () => {
call.mockResolvedValue({});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an empty instrument list with the public unavailable code', async () => {
call.mockResolvedValue({ instruments: [] });
const failure = await client
.getAllShares(token)
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ServiceUnavailableException);
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
code: 'TBANK_UNAVAILABLE',
});
});
it('rejects a non-list instrument payload as uninterpretable', async () => {
call.mockResolvedValue({ instruments: 'corrupted' });
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an uninterpretable entry without instrument identity', async () => {
call.mockResolvedValue({
instruments: [
{ ticker: 'FIT', uid: 'uid-fit' },
{ ticker: 'BROKEN' },
],
});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
call.mockResolvedValue({
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
});
await client.getAllShares(token);
expect(logger.event).toHaveBeenCalledWith(
'info',
'tbank.shares.catalog.fetched',
expect.objectContaining({ count: 2, operation: 'shares' }),
);
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
});
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
call.mockResolvedValue({ instruments: [] });
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.shares.catalog.unusable',
expect.objectContaining({ operation: 'shares' }),
);
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
'secret-token',
);
});
});
+67 -47
View File
@@ -29,6 +29,15 @@ import type {
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time'; import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
TbankAccountStatus,
TbankCandleInterval,
TbankInstrumentStatus,
TbankInstrumentType,
TbankOperationState,
} from './tbank-protocol';
const INITIAL_REQUEST_COUNT = 0; const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1; const MIN_RATE_DELAY_MS = 1;
@@ -38,10 +47,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;
@@ -115,7 +122,7 @@ export type TbankOperationsByCursorRequest = {
from?: TbankTimestamp; from?: TbankTimestamp;
instrumentId?: string; instrumentId?: string;
limit?: number; limit?: number;
state: 'OPERATION_STATE_EXECUTED'; state: TbankOperationState.Executed;
to?: TbankTimestamp; to?: TbankTimestamp;
}; };
export type TbankOperationsByCursorResponse = { export type TbankOperationsByCursorResponse = {
@@ -205,10 +212,10 @@ export class TbankClient {
loadPackageDefinition(definition) as unknown as TbankNamespace loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath ? const credentials = caPath
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request. // eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
ChannelCredentials.createSsl(readFileSync(caPath)) : ? ChannelCredentials.createSsl(readFileSync(caPath))
ChannelCredentials.createSsl(); : ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
@@ -256,7 +263,7 @@ export class TbankClient {
token, token,
this.users, this.users,
'getAccounts', 'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' }, { status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []); ).then((response) => response.accounts ?? []);
} }
@@ -279,14 +286,14 @@ export class TbankClient {
token: string, token: string,
figi: string, figi: string,
): Promise<null | TbankInstrument> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); return this.getInstrument(token, InstrumentIdType.Figi, figi);
} }
getInstrumentByUid( getInstrumentByUid(
token: string, token: string,
instrumentUid: string, instrumentUid: string,
): Promise<null | TbankInstrument> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
} }
getDividends( getDividends(
@@ -314,11 +321,40 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE', instrumentKind: TbankInstrumentType.Share,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
async getAllShares(token: string): Promise<TbankShare[]> {
const response = await this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'shares',
{ instrumentStatus: TbankInstrumentStatus.All },
);
const { instruments } = response;
if (
!Array.isArray(instruments) ||
!instruments.length ||
instruments.some((item) => !item?.uid)
) {
this.logger.event('error', 'tbank.shares.catalog.unusable', {
code: TbankErrorCode.Unavailable,
operation: 'shares',
});
throw new ServiceUnavailableException({
code: TbankErrorCode.Unavailable,
});
}
this.logger.event('info', 'tbank.shares.catalog.fetched', {
asOf: new Date().toISOString(),
count: instruments.length,
operation: 'shares',
});
return instruments;
}
findBonds(token: string, query: string): Promise<TbankBond[]> { findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>( return this.call<{ instruments?: TbankBond[] }>(
token, token,
@@ -326,7 +362,7 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_BOND', instrumentKind: TbankInstrumentType.Bond,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
@@ -337,7 +373,7 @@ export class TbankClient {
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
@@ -367,7 +403,7 @@ export class TbankClient {
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
@@ -411,12 +447,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,
@@ -446,7 +476,7 @@ export class TbankClient {
to: { to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
}, },
interval: 'CANDLE_INTERVAL_DAY', interval: TbankCandleInterval.Day,
limit: 2400, limit: 2400,
}, },
).then((response) => response.candles ?? []); ).then((response) => response.candles ?? []);
@@ -454,7 +484,7 @@ export class TbankClient {
private async getInstrument( private async getInstrument(
token: string, token: string,
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType,
id: string, id: string,
): Promise<null | TbankInstrument> { ): Promise<null | TbankInstrument> {
try { try {
@@ -478,13 +508,12 @@ 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 {
if (queueFull) { if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
const response = await this.queue.run(() => const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request), this.request<Response>(token, client, method, request),
@@ -497,44 +526,35 @@ export class TbankClient {
return response; return response;
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull ? const failureContext = queueFull
{ code: 'TBANK_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 (
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode) [status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) { ) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary. // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN'); throw new Error(TbankErrorCode.InvalidToken);
} }
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
@@ -542,10 +562,10 @@ export class TbankClient {
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
} }
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
private request<Response>( private request<Response>(
-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,
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
+19 -16
View File
@@ -8,8 +8,11 @@ import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1; const EXPECTED_ROTATED_SESSIONS = 1;
@@ -32,12 +35,12 @@ 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 },
}); });
if (existing) { if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
let user: User; let user: User;
@@ -51,7 +54,7 @@ export class AuthService {
}); });
} catch (error) { } catch (error) {
if (isUniqueConstraintError(error)) { if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
throw error; throw error;
} }
@@ -61,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 },
}); });
@@ -70,24 +73,24 @@ export class AuthService {
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
); );
if (!user || !valid) { if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
} }
return this.createSession(user); return this.createSession(user);
} }
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, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
} catch { } catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (payload.type !== 'refresh') { if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: payload.sid }, where: { id: payload.sid },
@@ -102,10 +105,10 @@ export class AuthService {
if (session && !session.revokedAt) { if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.prisma.session.delete({ where: { id: session.id } });
} }
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (session.refreshTokenHash !== this.hash(token)) { if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
return this.rotateSession(session, session.refreshTokenHash); return this.rotateSession(session, session.refreshTokenHash);
} }
@@ -142,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
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(),
@@ -163,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,
@@ -183,7 +186,7 @@ export class AuthService {
data, data,
}); });
if (updated.count !== EXPECTED_ROTATED_SESSIONS) { if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
} else { } else {
await this.prisma.session.update({ where: { id: session.id }, data }); await this.prisma.session.update({ where: { id: session.id }, data });
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' && typeof error === 'object' &&
error !== null && error !== null &&
'code' in error && 'code' in error &&
(error as { code?: unknown }).code === 'P2002' isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
); );
} }
@@ -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;
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
validate(payload: AccessPayload): AccessPayload { validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') { if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
return payload; return payload;
} }
@@ -21,8 +21,9 @@ import {
} from '@nestjs/swagger'; } from '@nestjs/swagger';
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 { 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';
@@ -84,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: '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')
@@ -116,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);
} }
@@ -22,24 +22,23 @@ import {
const NEXT_MONTH_OFFSET = 1; const NEXT_MONTH_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0; const PREVIOUS_MONTH_LAST_DAY = 0;
export const BankErrorCode = { export enum BankErrorCode {
InvalidInput: 'BANK_INVALID_INPUT', CardArchived = 'BANK_CARD_ARCHIVED',
VersionConflict: 'BANK_VERSION_CONFLICT', DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
NotFound: 'BANK_NOT_FOUND', DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
InvalidMoney: 'BANK_INVALID_MONEY', ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
InvalidDate: 'BANK_INVALID_DATE', IntentConflict = 'BANK_INTENT_CONFLICT',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', InvalidDate = 'BANK_INVALID_DATE',
InvalidRefund: 'BANK_INVALID_REFUND', InvalidInput = 'BANK_INVALID_INPUT',
RefundLimit: 'BANK_REFUND_LIMIT', InvalidMoney = 'BANK_INVALID_MONEY',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS', InvalidRefund = 'BANK_INVALID_REFUND',
IntentConflict: 'BANK_INTENT_CONFLICT', NotFound = 'BANK_NOT_FOUND',
OperationDeleted: 'BANK_OPERATION_DELETED', OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
CardArchived: 'BANK_CARD_ARCHIVED', OperationDeleted = 'BANK_OPERATION_DELETED',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', RefundLimit = 'BANK_REFUND_LIMIT',
WriteConflict: 'BANK_WRITE_CONFLICT', VersionConflict = 'BANK_VERSION_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', WriteConflict = 'BANK_WRITE_CONFLICT',
} as const; }
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
@@ -103,17 +103,13 @@ 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
dto.paymentSystem, : dto.paymentSystem,
openingBalance: openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks), dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate, openingDate: dto.openingDate ?? previous.openingDate,
@@ -143,12 +139,12 @@ export class BankCardsService {
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
...(dto.archived === undefined ? ...(dto.archived === undefined
{} : ? {}
{ : {
archivedAt: dto.archived ? archivedAt: dto.archived
(previous.archivedAt ?? new Date()) : ? (previous.archivedAt ?? new Date())
null, : null,
}), }),
}, },
}); });
@@ -43,29 +43,29 @@ export class BankOperationsService {
row: BankCardOperation, row: BankCardOperation,
): Promise<BankOperationDto> { ): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId); const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId ? const target = row.targetCardId
await this.repository.card(tx, row.userId, row.targetCardId) : ? await this.repository.card(tx, row.userId, row.targetCardId)
null; : null;
const source = row.originalExpenseId ? const source = row.originalExpenseId
await this.repository.operation(tx, row.userId, row.originalExpenseId) : ? await this.repository.operation(tx, row.userId, row.originalExpenseId)
null; : null;
const categoryId = source?.categoryId ?? row.categoryId; const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId ? const category = categoryId
await tx.expenseCategory.findFirst({ ? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId }, where: { id: categoryId, userId: row.userId },
}) : })
null; : null;
const refunds = const refunds =
row.kind === BankOperationKind.Expense || source ? row.kind === BankOperationKind.Expense || source
await tx.bankCardOperation.findMany({ ? await tx.bankCardOperation.findMany({
where: { where: {
userId: row.userId, userId: row.userId,
originalExpenseId: source?.id ?? row.id, originalExpenseId: source?.id ?? row.id,
deletedAt: null, deletedAt: null,
...(source ? { id: { not: row.id } } : {}), ...(source ? { id: { not: row.id } } : {}),
}, },
}) : })
[]; : [];
return { return {
id: row.id, id: row.id,
kind: row.kind as BankOperationDto['kind'], kind: row.kind as BankOperationDto['kind'],
@@ -78,12 +78,12 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId, originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks), amount: rubMoney(row.amountKopecks),
availableRefund: availableRefund:
row.kind === BankOperationKind.Expense || source ? row.kind === BankOperationKind.Expense || source
rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS), refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) : )
null, : null,
date: row.date, date: row.date,
description: row.description, description: row.description,
version: row.version, version: row.version,
@@ -336,9 +336,9 @@ export class BankOperationsService {
query: BankOperationsQueryDto | BankPageQueryDto, query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false, expenses = false,
) { ) {
const period = expenses ? const period = expenses
undefined : ? undefined
periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
@@ -347,9 +347,9 @@ export class BankOperationsService {
userId, userId,
deletedAt: null, deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
...(period ? ...(period
{ date: { gte: period.from, lte: period.to } } : ? { date: { gte: period.from, lte: period.to } }
{ kind: BankOperationKind.Expense }), : { kind: BankOperationKind.Expense }),
}; };
const total = await tx.bankCardOperation.count({ where }); const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
@@ -81,13 +81,13 @@ export function bankSummary(
continue; continue;
} }
const expense = const expense =
operation.kind === BankOperationKind.Expense ? operation.kind === BankOperationKind.Expense
operation.amountKopecks : ? operation.amountKopecks
ZERO_KOPECKS; : ZERO_KOPECKS;
const refund = const refund =
operation.kind === BankOperationKind.Refund ? operation.kind === BankOperationKind.Refund
operation.amountKopecks : ? operation.amountKopecks
ZERO_KOPECKS; : ZERO_KOPECKS;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client'; import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
@@ -40,19 +41,19 @@ export class BankCardsRepository {
}); });
} catch (error) { } catch (error) {
const code = const code =
error instanceof Prisma.PrismaClientKnownRequestError ? error instanceof Prisma.PrismaClientKnownRequestError
error.code : ? error.code
''; : '';
const busy = const busy =
code === 'P2034' || isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
code === 'P2028' || 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 || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) { if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
continue; continue;
} }
if (busy) conflict(BankErrorCode.WriteConflict); if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
throw error; throw error;
} }
} }
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1 +0,0 @@
export { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
@Get() @Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto }) @ApiOkResponse({ type: BrokerageAccountsResourceDto })
list( list(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.accounts.list(request.user.sub);
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
} }
} }
@@ -3,11 +3,12 @@ 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 { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, PortfolioModule], imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [BrokerageAccountsController], controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService], providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService], exports: [BrokerageAccountsService],
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => { describe('saved brokerage account projection', () => {
const portfolio = { saved: jest.fn() }; const portfolio = { saved: jest.fn() };
const service = new BrokerageAccountsService(portfolio as never); const sync = { hourly: jest.fn().mockResolvedValue(null) };
const service = new BrokerageAccountsService(portfolio as never, sync as never);
it('distinguishes unknown membership from a confirmed empty list', async () => { it('distinguishes unknown membership from a confirmed empty list', async () => {
portfolio.saved.mockResolvedValueOnce({ portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' }, membership: { ...meta, completeness: 'incomplete' },
accounts: [], accounts: [],
}); });
expect((await service.list('owner', true)).data).toBeNull(); expect((await service.list('owner')).data).toBeNull();
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
expect((await service.list('owner', true)).data).toEqual([]); expect((await service.list('owner')).data).toEqual([]);
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
@@ -58,13 +59,46 @@ describe('saved brokerage account projection', () => {
}, },
], ],
}); });
const response = await service.list('owner', true); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('partial'); expect(response.meta.availability).toBe('available');
expect(response.meta.completeness).toBe('complete');
});
it('observes the latest hourly walk without starting another one', async () => {
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
sync.hourly.mockResolvedValue({
startedAt: new Date('2026-09-25T12:00:00Z'),
finishedAt: new Date('2026-09-25T12:01:00Z'),
state: 'partial',
sections: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
const response = await service.list('owner');
expect(response.sync).toEqual({
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
state: 'partial',
problems: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
expect(sync.hourly).toHaveBeenCalledWith('owner');
}); });
}); });
@@ -3,24 +3,28 @@ import { createHash } from 'node:crypto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
constructor(private readonly portfolio: PortfolioService) {} constructor(
private readonly portfolio: PortfolioService,
private readonly sync: TbankSyncRepository,
) {}
async list(userId: string, observe = false) { async list(userId: string): Promise<BrokerageAccountsResourceDto> {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all');
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' ? saved.membership.completeness === 'incomplete'
null : ? null
saved.accounts.map((account) => ({ : saved.accounts.map((account) => ({
id: account.id, id: account.id,
name: account.name, name: account.name,
type: account.type as BrokerageAccountDto['type'], type: account.type as BrokerageAccountDto['type'],
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot ? portfolioSummary: account.snapshot
{ ? {
totalsByCurrency: account.snapshot.totalsByCurrency, totalsByCurrency: account.snapshot.totalsByCurrency,
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
@@ -28,9 +32,11 @@ export class BrokerageAccountsService {
bondPositionCount: account.snapshot.positions.filter( bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND', (position) => position.type === 'BOND',
).length, ).length,
} : }
null, : null,
})); }));
// The read-only result of the last hourly walk; never starts one.
const hourly = await this.sync.hourly(userId);
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -47,20 +53,27 @@ export class BrokerageAccountsService {
) )
.digest('base64url'), .digest('base64url'),
availability: availability:
data === null ? data === null
'missing' : ? 'missing'
saved.accounts.some((account) => !account.snapshot) ? : 'complete',
'partial' : fetchedAt: saved.membership.lastSuccessfulAt
'complete', ? new Date(saved.membership.lastSuccessfulAt)
fetchedAt: saved.membership.lastSuccessfulAt ? : null,
new Date(saved.membership.lastSuccessfulAt) : freshForMs: 3_600_000,
null, work: null,
freshForMs: 300_000,
work: saved.membership.sync.id ?
{ ...saved.membership.sync, id: saved.membership.sync.id } :
null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly
? {
state: hourly.state,
startedAt: hourly.startedAt.toISOString(),
finishedAt: hourly.finishedAt?.toISOString() ?? null,
problems: hourly.sections.map((section) => ({
...section,
accountId: section.accountId ?? null,
})),
}
: null,
}; };
} }
} }
@@ -48,7 +48,7 @@ describe('IncomeAnalyticsService', () => {
expect.objectContaining({ role: 'previous' }), expect.objectContaining({ role: 'previous' }),
expect.objectContaining({ expect.objectContaining({
role: 'year', role: 'year',
availability: 'missing', availability: 'temporarily-unavailable',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}), }),
]); ]);
@@ -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(
( (
@@ -8,6 +8,7 @@ import {
formatDecimal, formatDecimal,
subtract, subtract,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
@@ -96,9 +97,9 @@ export class IncomeAnalyticsService {
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' }, { ...prior.meta, role: 'previous' },
{ {
...(plan.yearAgo && year ? ...(plan.yearAgo && year
year.meta : ? year.meta
investmentResourceMeta({ : investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable', dataRevision: 'unavailable',
availability: 'missing', availability: 'missing',
@@ -118,10 +119,13 @@ export class IncomeAnalyticsService {
...investmentResourceMeta({ ...investmentResourceMeta({
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision, dataRevision: current.meta.dataRevision,
availability: current.meta.availability, availability:
fetchedAt: current.meta.lastSuccessfulAt ? current.items === null
new Date(current.meta.lastSuccessfulAt) : ? 'temporarily-unavailable'
null, : 'available',
fetchedAt: current.meta.lastSuccessfulAt
? new Date(current.meta.lastSuccessfulAt)
: null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage, coverage: current.meta.coverage,
@@ -144,13 +148,13 @@ export class IncomeAnalyticsService {
current.meta.lastSuccessfulAt, current.meta.lastSuccessfulAt,
), ),
previous: previous:
prior.items === null ? prior.items === null
null : ? null
comparison(selected, plan.previous, qualifying(prior.items)), : comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison: yearComparison:
plan.yearAgo && year?.items !== null && year ? plan.yearAgo && year?.items !== null && year
comparison(selected, plan.yearAgo, qualifying(year.items)) : ? comparison(selected, plan.yearAgo, qualifying(year.items))
null, : null,
bucketGranularity: plan.granularity, bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected), buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected), instruments: instruments(selected),
@@ -165,7 +169,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter( return entries.filter(
(entry) => (entry) =>
Boolean(entry.payment) && Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type), [TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
); );
} }
@@ -184,7 +188,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
if (!entry.payment) continue; if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal(); const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value); const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON') { if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value); total.coupon = add(total.coupon, value);
} else total.dividend = add(total.dividend, value); } else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total); values.set(entry.payment.currency, total);
@@ -231,13 +235,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),
}),
), ),
})); }));
} }
+24 -22
View File
@@ -8,6 +8,8 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
const MONDAY_WEEKDAY_INDEX = 1; const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1; const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
@@ -39,24 +41,24 @@ export function planIncomePeriod(
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected) ? const previous = isFullMonth(selected)
previousMonth(selected.from) : ? previousMonth(selected.from)
shiftDays(selected, -rangeDays(selected)); : shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected); const candidate = yearAgo(selected);
const yearAgoRange = const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ? candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
candidate : ? candidate
null; : null;
return { return {
selected, selected,
previous, previous,
yearAgo: yearAgoRange, yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ? rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
'day' : ? 'day'
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ? : rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
'week' : ? 'week'
'month', : 'month',
}; };
} }
@@ -69,20 +71,20 @@ export function rangeBuckets(
} }
const buckets: DateOnlyRange[] = []; const buckets: DateOnlyRange[] = [];
let cursor = let cursor =
granularity === 'week' ? granularity === 'week'
addDays( ? addDays(
range.from, range.from,
-( -(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) % (utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
DAYS_PER_WEEK DAYS_PER_WEEK
), ),
) : )
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`; : `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) { while (cursor <= range.to) {
const end = const end =
granularity === 'week' ? granularity === 'week'
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) : ? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
endOfMonth(cursor); : endOfMonth(cursor);
buckets.push({ buckets.push({
from: cursor < range.from ? range.from : cursor, from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end, to: end > range.to ? range.to : end,
@@ -153,9 +155,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => { const shift = (value: string) => {
const [year, month, day] = dateParts(value); const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`; const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ? return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
result : ? result
null; : null;
}; };
const from = shift(range.from); const from = shift(range.from);
const to = shift(range.to); const to = shift(range.to);
@@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
} }
function invalidPeriod(): BadRequestException { function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' }); return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
} }
@@ -1 +0,0 @@
export { IncomeAnalyticsService } from './income-analytics.service';
@@ -5,10 +5,9 @@ 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 { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
@@ -16,21 +15,21 @@ 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,
candleWindows, candleWindows,
utcDay, utcDay,
} from '../domain/candle-coverage'; } from '../domain/candle-coverage';
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;
const NO_CANDLE_GAPS = 0;
type CandleInput = { type CandleInput = {
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
@@ -54,7 +53,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()}` : ''}`;
} }
@@ -70,9 +68,9 @@ function rangeFor(
if (!history) { if (!history) {
throw new ServiceUnavailableException({ throw new ServiceUnavailableException({
code: code:
input.instrumentType === 'share' ? input.instrumentType === 'share'
'STOCK_HISTORY_START_UNAVAILABLE' : ? InstrumentErrorCode.StockHistoryStartUnavailable
'BOND_HISTORY_START_UNAVAILABLE', : InstrumentErrorCode.BondHistoryStartUnavailable,
}); });
} }
from.setTime(utcDay(new Date(history)).valueOf()); from.setTime(utcDay(new Date(history)).valueOf());
@@ -92,7 +90,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,
@@ -110,16 +107,18 @@ export class CandleService {
true, true,
['detail'], ['detail'],
); );
if (detail.meta.availability === 'complete' && !detail.instrument) { if (detail.meta.completeness === 'complete' && !detail.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const now = new Date(); const now = new Date();
const range = detail.instrument ? const range = detail.instrument
rangeFor(input, detail.instrument, now) : ? rangeFor(input, detail.instrument, now)
null; : null;
const saved = range ? await this.repository.read(input.uid, range) : null; const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : []; const hasCompleteRange = Boolean(
if (!observe && (!saved || gaps.length)) { range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
);
if (!observe && (!saved || !hasCompleteRange)) {
await this.request(userId, revision, input, now); await this.request(userId, revision, input, now);
} }
const work = await this.repository.work(userId, revision, scope(input)); const work = await this.repository.work(userId, revision, scope(input));
@@ -139,14 +138,14 @@ export class CandleService {
}); });
return { return {
data: data:
!saved || !covered ? !saved || !covered
null : ? null
{ : {
candles: saved.data, candles: saved.data,
currency: currency:
input.instrumentType === 'share' ? input.instrumentType === 'share'
detail.instrument?.currency?.toUpperCase() || null : ? detail.instrument?.currency?.toUpperCase() || null
null, : null,
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate), historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
observedAt: fetchedAt?.toISOString() ?? now.toISOString(), observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
}, },
@@ -159,8 +158,8 @@ export class CandleService {
work, work,
coverage: { coverage: {
requested: range ? [publicRange(range)] : [], requested: range ? [publicRange(range)] : [],
completeRanges: range ? completeRanges: range
completeCoverage.flatMap((row) => { ? completeCoverage.flatMap((row) => {
const from = new Date( const from = new Date(
Math.max(row.from.valueOf(), range.from.valueOf()), Math.max(row.from.valueOf(), range.from.valueOf()),
); );
@@ -168,22 +167,22 @@ export class CandleService {
Math.min(row.to.valueOf(), range.to.valueOf()), Math.min(row.to.valueOf(), range.to.valueOf()),
); );
return from < to ? [publicRange({ from, to })] : []; return from < to ? [publicRange({ from, to })] : [];
}) : })
[], : [],
missingRanges: missing.map(publicRange), missingRanges: missing.map(publicRange),
asOf: fetchedAt?.toISOString() ?? null, asOf: fetchedAt?.toISOString() ?? null,
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ? historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
'known' : ? 'known'
'unknown', : 'unknown',
historyState: complete ? historyState: complete
'complete' : ? 'complete'
work?.state === 'failed' ? : work?.state === 'failed'
'failed' : ? 'failed'
work?.state === 'running' ? : work?.state === 'running'
'loading' : ? 'loading'
covered ? : covered
'partial' : ? 'partial'
'missing', : 'missing',
}, },
dependencies: [ dependencies: [
detail.meta, detail.meta,
@@ -193,9 +192,9 @@ export class CandleService {
dataRevision: String(row.dataRevision), dataRevision: String(row.dataRevision),
availability: 'complete', availability: 'complete',
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
freshForMs: row.isComplete ? freshForMs: row.isComplete
Number.POSITIVE_INFINITY : ? Number.POSITIVE_INFINITY
tailFreshForMs, : tailFreshForMs,
work: null, work: null,
}), }),
), ),
@@ -204,110 +203,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,
@@ -411,17 +306,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;
} }
@@ -448,7 +333,7 @@ export class CandleService {
const producer = this.queue.shift(); const producer = this.queue.shift();
if (!producer) break; if (!producer) break;
let success = false; let success = false;
let code = 'TBANK_UNAVAILABLE'; let code: string = TbankErrorCode.Unavailable;
try { try {
const result = await this.load(producer); const result = await this.load(producer);
if (result === null) { if (result === null) {
@@ -457,7 +342,7 @@ export class CandleService {
} }
success = result; success = result;
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
} }
if (error instanceof ServiceUnavailableException) { if (error instanceof ServiceUnavailableException) {
@@ -508,22 +393,11 @@ 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 {
this.logger.event('error', 'candles.persistence-failed', { this.logger.event('error', 'candles.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.active.clear(); this.active.clear();
@@ -6,14 +6,17 @@ import {
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond,
TbankClient, TbankClient,
tbankSourceNeedsRefresh, type TbankShare,
tbankSourceTtlMs, tbankSourceTtlMs,
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 { InstrumentErrorCode } from '../domain/instrument-error-code';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
@@ -29,6 +32,7 @@ import {
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0; const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const MISSING_DATA_REVISION = '0';
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
@@ -71,22 +75,23 @@ export class CatalogDiscoveryService {
dataRevision: saved.dataRevision, dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete', availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt, fetchedAt: saved.fetchedAt,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input), work: await this.repository.work(userId, connection.revision, input),
}), }),
}; };
} }
async search(userId: string, input: CatalogQuery, observe = false) { async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input, observe); const discovery = await this.read(userId, input, observe);
const sources = discovery.data ? const sources = discovery.data
await this.enrichment.readMany( ? await this.enrichment.readMany(
userId, userId,
input.instrumentType, input.instrumentType,
discovery.data.map((item) => item.uid), discovery.data.map((item) => item.uid),
observe, observe,
) : )
[]; : [];
const dependencies = [ const dependencies = [
discovery.meta, discovery.meta,
...sources.map((source) => source.meta), ...sources.map((source) => source.meta),
@@ -114,22 +119,22 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: availability:
discovery.data === null ? discovery.data === null
'missing' : ? 'missing'
sources.every((source) => source.meta.availability === 'complete') ? : sources.every((source) => source.meta.completeness === 'complete')
'complete' : ? 'complete'
'partial', : 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt ? fetchedAt: discovery.meta.lastSuccessfulAt
new Date(discovery.meta.lastSuccessfulAt) : ? new Date(discovery.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.catalog, freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id ? work: discovery.meta.sync.id
{ ? {
id: discovery.meta.sync.id, id: discovery.meta.sync.id,
state: discovery.meta.sync.state, state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode, errorCode: discovery.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
@@ -141,6 +146,25 @@ export class CatalogDiscoveryService {
uid: string, uid: string,
observe = false, observe = false,
) { ) {
if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
const sources = await this.enrichment.readMany( const sources = await this.enrichment.readMany(
userId, userId,
instrumentType, instrumentType,
@@ -149,7 +173,7 @@ export class CatalogDiscoveryService {
['detail'], ['detail'],
); );
const source = sources[0]; const source = sources[0];
if (source.meta.availability === 'complete' && !source.instrument) { if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const instrument = source.instrument; const instrument = source.instrument;
@@ -177,28 +201,53 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: source.meta.availability, availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt ? fetchedAt: source.meta.lastSuccessfulAt
new Date(source.meta.lastSuccessfulAt) : ? new Date(source.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id ? work: source.meta.sync.id
{ ? {
id: source.meta.sync.id, id: source.meta.sync.id,
state: source.meta.sync.state, state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode, errorCode: source.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker( async byTicker(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false, observe = false,
) { ) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId);
const found =
instrumentType === 'share'
? await this.tbank.findShares(token, query)
: await this.tbank.findBonds(token, query);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
instrumentType,
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read( const lookup = await this.read(
userId, userId,
{ instrumentType, query: ticker, exactTicker: true }, { instrumentType, query: ticker, exactTicker: true },
@@ -208,41 +257,86 @@ export class CatalogDiscoveryService {
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) { if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' ? instrumentType === 'share'
'STOCK_TICKER_AMBIGUOUS' : ? InstrumentErrorCode.StockTickerAmbiguous
'BOND_TICKER_AMBIGUOUS', : InstrumentErrorCode.BondTickerAmbiguous,
}); });
} }
const result = lookup.data ? const result = lookup.data
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) : ? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
null; : null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])]; const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return { return {
data: result?.data ? data: result?.data
{ ...result.data, slug: ticker.trim().toUpperCase() } : ? { ...result.data, slug: ticker.trim().toUpperCase() }
null, : null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`, resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: result?.meta.availability ?? 'missing', availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt ? fetchedAt: lookup.meta.lastSuccessfulAt
new Date(lookup.meta.lastSuccessfulAt) : ? new Date(lookup.meta.lastSuccessfulAt)
null, : null,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id ? work: lookup.meta.sync.id
{ ? {
id: lookup.meta.sync.id, id: lookup.meta.sync.id,
state: lookup.meta.sync.state, state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode, errorCode: lookup.meta.sync.errorCode,
} : }
null, : null,
dependencies, dependencies,
}), }),
}; };
} }
private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId);
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const details = await Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
);
const prices = instruments.length
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [];
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
});
return {
data,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(input)}`,
dataRevision: `online:${fetchedAt.toISOString()}`,
availability: 'complete',
fetchedAt,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
request( request(
userId: string, userId: string,
revision: string, revision: string,
@@ -253,10 +347,9 @@ export class CatalogDiscoveryService {
const reserved = this.reservations.then(async () => { const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
if ( if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) !force &&
) { saved.dataRevision !== MISSING_DATA_REVISION
return null; ) return null;
}
const reservation = await this.repository.reserve( const reservation = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -334,19 +427,19 @@ export class CatalogDiscoveryService {
const token = await this.access.tokenForAttempt(attempt); const token = await this.access.tokenForAttempt(attempt);
if (token) { if (token) {
const found = const found =
input.instrumentType === 'share' ? input.instrumentType === 'share'
await this.tbank.findShares(token, input.query) : ? await this.tbank.findShares(token, input.query)
await this.tbank.findBonds(token, input.query); : await this.tbank.findBonds(token, input.query);
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
input, input,
selectCatalogInstruments( selectCatalogInstruments(
input.exactTicker ? input.exactTicker
found.filter( ? found.filter(
(item) => (item) =>
item.ticker?.trim().toUpperCase() === input.query, item.ticker?.trim().toUpperCase() === input.query,
) : )
found, : found,
input.query, input.query,
input.instrumentType, input.instrumentType,
).slice( ).slice(
@@ -357,11 +450,11 @@ export class CatalogDiscoveryService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
} else { } else {
this.logger.event('warn', 'catalog-discovery.failed', { this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} }
} }
@@ -395,10 +488,44 @@ export class CatalogDiscoveryService {
this.producers.clear(); this.producers.clear();
this.queue.length = 0; this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', { this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.running = false; this.running = false;
} }
} }
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -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,7 @@ 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 { import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
@@ -36,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';
@@ -58,7 +58,7 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ done: Promise<void>; resolve: () => void; workId: string } { attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
@@ -124,9 +124,9 @@ export class CatalogEnrichmentService {
range, range,
); );
} }
const work = inputs.length ? const work = inputs.length
await this.repository.workMany(userId, revision, inputs) : ? await this.repository.workMany(userId, revision, inputs)
[]; : [];
const byScope = new Map(work.map((row) => [row.scopeKey, row])); const byScope = new Map(work.map((row) => [row.scopeKey, row]));
return inputs.map((input) => { return inputs.map((input) => {
const row = rows.find( const row = rows.find(
@@ -136,16 +136,16 @@ export class CatalogEnrichmentService {
const payload = row?.payload as CatalogPayload | undefined; const payload = row?.payload as CatalogPayload | undefined;
return { return {
input, input,
version: row ? version: row
{ ? {
instrumentUid: row.instrumentUid, instrumentUid: row.instrumentUid,
source: row.source, source: row.source,
kind: row.kind, kind: row.kind,
rangeKey: row.rangeKey, rangeKey: row.rangeKey,
dataRevision: row.dataRevision, dataRevision: row.dataRevision,
fetchedAt: row.fetchedAt, fetchedAt: row.fetchedAt,
} : }
null, : null,
payload, payload,
instrument: payload?.instrument ?? null, instrument: payload?.instrument ?? null,
price: payload?.price ?? null, price: payload?.price ?? null,
@@ -154,7 +154,7 @@ export class CatalogEnrichmentService {
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
work: byScope.get(catalogScope(input)) ?? null, work: byScope.get(catalogScope(input)) ?? null,
}), }),
}; };
@@ -177,23 +177,19 @@ export class CatalogEnrichmentService {
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => { const reservation = this.reservations.then(async () => {
const rows = inputs.length ? const rows = inputs.length
await this.repository.readSources(inputs) : ? await this.repository.readSources(inputs)
[]; : [];
const ids: string[] = []; const ids: string[] = [];
for (const input of inputs) { for (const input of inputs) {
const row = rows.find( const row = rows.find(
(entry) => (entry) =>
entry.instrumentUid === input.uid && entry.kind === input.kind, entry.instrumentUid === input.uid && entry.kind === input.kind,
); );
if ( const policy = sourcePolicy(input.kind);
!tbankSourceNeedsRefresh( const needsRefresh = force || !row?.fetchedAt ||
sourcePolicy(input.kind), (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
row?.fetchedAt ?? null, if (!needsRefresh) {
new Date(),
force,
)
) {
continue; continue;
} }
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
@@ -212,7 +208,7 @@ export class CatalogEnrichmentService {
const done = new Promise<void>((finish) => { const done = new Promise<void>((finish) => {
resolve = finish; resolve = finish;
}); });
this.completions.set(attemptId, { workId: id, done, resolve }); this.completions.set(attemptId, { attempt, done, resolve, workId: id });
const key = producerKey(input); const key = producerKey(input);
const producer = this.producers.get(key); const producer = this.producers.get(key);
if (producer) producer.observers.push({ attempt, input }); if (producer) producer.observers.push({ attempt, input });
@@ -234,6 +230,7 @@ export class CatalogEnrichmentService {
return reservation; return reservation;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section( async section(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
@@ -241,19 +238,47 @@ export class CatalogEnrichmentService {
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid
? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid),
])
: [null, null];
const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: Number.MAX_SAFE_INTEGER,
work: null,
});
return {
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
meta,
unavailable: [],
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
section === 'company' ? section === 'company'
['detail', 'description', 'fundamentals'] : ? ['detail', 'description', 'fundamentals']
[ : [
...(instrumentType === 'bond' ? ...(instrumentType === 'bond'
['bond-current' as const] : ? ['bond-current' as const]
['detail' as const]), : ['detail' as const]),
'last-price', 'last-price',
'trading-status', 'trading-status',
'comparison', 'comparison',
...(section === 'metrics' ? ...(section === 'metrics'
['coupons' as const, 'yield' as const, 'rating' as const] : ? ['coupons' as const, 'yield' as const, 'rating' as const]
[]), : []),
]; ];
const sources = await this.readMany( const sources = await this.readMany(
userId, userId,
@@ -263,7 +288,7 @@ export class CatalogEnrichmentService {
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument) { if (first.meta.completeness === 'complete' && !first.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const values = sources.reduce<CatalogPayload>( const values = sources.reduce<CatalogPayload>(
@@ -272,25 +297,22 @@ export class CatalogEnrichmentService {
); );
const dependencies = sources.map((source) => source.meta); const dependencies = sources.map((source) => source.meta);
const complete = dependencies.every( const complete = dependencies.every(
(dependency) => dependency.availability === 'complete', (dependency) => dependency.completeness === 'complete',
); );
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/${section}/${uid}`, resourceKey: `${instrumentType}/${section}/${uid}`,
dataRevision: dependencies dataRevision: dependencies
.map((dependency) => dependency.dataRevision) .map((dependency) => dependency.dataRevision)
.join('/'), .join('/'),
availability: !first.instrument ? availability: !first.instrument
'missing' : ? 'missing'
complete ? : complete
'complete' : ? 'complete'
'partial', : 'partial',
fetchedAt: first.meta.lastSuccessfulAt ? fetchedAt: first.meta.lastSuccessfulAt
new Date(first.meta.lastSuccessfulAt) : ? new Date(first.meta.lastSuccessfulAt)
null, : null,
freshForMs: freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
section === 'company' ?
tbankSourceTtlMs.company :
tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -300,7 +322,7 @@ export class CatalogEnrichmentService {
unavailable: sources unavailable: sources
.filter( .filter(
(source) => (source) =>
source.meta.availability !== 'complete' || source.meta.completeness !== 'complete' ||
source.meta.sync.state === 'failed', source.meta.sync.state === 'failed',
) )
.map((source) => source.input.kind), .map((source) => source.input.kind),
@@ -308,6 +330,7 @@ export class CatalogEnrichmentService {
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
private async load( private async load(
input: CatalogSource, input: CatalogSource,
token: string, token: string,
@@ -315,15 +338,15 @@ export class CatalogEnrichmentService {
): Promise<CatalogPayload> { ): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') { if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument = const instrument =
input.instrumentType === 'share' ? input.instrumentType === 'share'
await this.tbank.getShare(token, input.uid) : ? await this.tbank.getShare(token, input.uid)
await this.tbank.getBond(token, input.uid); : await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) { if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch'); throw new Error('Catalog UID mismatch');
} }
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response'); if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null }; return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
} }
if (input.kind === 'trading-status') { if (input.kind === 'trading-status') {
return { return {
@@ -352,17 +375,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');
} }
@@ -400,9 +413,9 @@ export class CatalogEnrichmentService {
} }
const details = await this.repository.readSources([ const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined }, { ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond' ? ...(input.instrumentType === 'bond'
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] : ? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
[]), : []),
]); ]);
const instrument = details const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument) .map((row) => (row.payload as CatalogPayload).instrument)
@@ -415,36 +428,36 @@ export class CatalogEnrichmentService {
} }
if (input.kind === 'description') { if (input.kind === 'description') {
return { return {
description: instrument?.assetUid ? description: instrument?.assetUid
await this.tbank.getAssetDescription(token, instrument?.assetUid) : ? await this.tbank.getAssetDescription(token, instrument?.assetUid)
null, : null,
}; };
} }
if (input.kind === 'fundamentals') { if (input.kind === 'fundamentals') {
return { return {
fundamentals: instrument?.assetUid ? fundamentals: instrument?.assetUid
await this.tbank.getFundamentals(token, instrument?.assetUid) : ? await this.tbank.getFundamentals(token, instrument?.assetUid)
null, : null,
}; };
} }
if (input.kind === 'coupons' || input.kind === 'dividends') { if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey); const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[') ? const [from, to] = input.rangeKey?.startsWith('[')
(JSON.parse(input.rangeKey) as [string, string]) : ? (JSON.parse(input.rangeKey) as [string, string])
[ : [
`${year - payoutHistoryYears}-01-01`, `${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`, `${year + payoutFutureYears}-01-01`,
]; ];
return input.kind === 'coupons' ? return input.kind === 'coupons'
{ ? {
coupons: await this.tbank.getBondCoupons( coupons: await this.tbank.getBondCoupons(
token, token,
input.uid, input.uid,
from, from,
to, to,
), ),
} : }
{ : {
dividends: await this.tbank.getDividends( dividends: await this.tbank.getDividends(
token, token,
input.uid, input.uid,
@@ -461,25 +474,26 @@ export class CatalogEnrichmentService {
if (input.kind === 'yield') { if (input.kind === 'yield') {
return { return {
effectiveYield: effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode ? instrument?.ticker && instrument.isin && instrument.classCode
await this.moex.bondYield( ? await this.moex.bondYield(
instrument.ticker, instrument.ticker,
instrument.isin, instrument.isin,
instrument.classCode, instrument.classCode,
) : )
null, : null,
}; };
} }
if (input.kind === 'rating') { if (input.kind === 'rating') {
return { return {
creditRating: instrument?.isin ? creditRating: instrument?.isin
await this.ratings.rating(instrument.isin) : ? await this.ratings.rating(instrument.isin)
null, : null,
}; };
} }
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
private async drain() { private async drain() {
try { try {
while (this.queue.length) { while (this.queue.length) {
@@ -520,11 +534,11 @@ export class CatalogEnrichmentService {
} }
} catch (error) { } catch (error) {
failed = true; failed = true;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') { if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt); await this.access.rejectMembershipToken(producer.attempt);
} else { } else {
this.logger.event('warn', 'catalog-enrichment.failed', { this.logger.event('warn', 'catalog-enrichment.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} }
} }
@@ -533,17 +547,17 @@ export class CatalogEnrichmentService {
prices?.find((entry) => entry.instrumentUid === item.input.uid) ?? prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
null; null;
const value = const value =
item.input.kind === 'last-price' ? item.input.kind === 'last-price'
{ ? {
price: price ? price: price
{ ? {
instrumentUid: item.input.uid, instrumentUid: item.input.uid,
price: price.price, price: price.price,
time: price.time, time: price.time,
} : }
null, : null,
} : }
payload; : payload;
// Keep accepting observers while publication awaits SQLite. // Keep accepting observers while publication awaits SQLite.
item.observers.unshift({ attempt: item.attempt, input: item.input }); item.observers.unshift({ attempt: item.attempt, input: item.input });
for (const observer of item.observers) { for (const observer of item.observers) {
@@ -566,13 +580,21 @@ export class CatalogEnrichmentService {
} }
} }
} catch { } catch {
// Persistence failed mid-batch: finish every still-open observer
// attempt durably so no source row is left running. Best effort — a
// row that stays open is re-reserved by the next reader.
for (const completion of this.completions.values()) {
await this.repository
.finish(completion.attempt, false)
.catch(() => undefined);
}
for (const completion of this.completions.values()) completion.resolve(); for (const completion of this.completions.values()) completion.resolve();
this.completions.clear(); this.completions.clear();
this.active.clear(); this.active.clear();
this.producers.clear(); this.producers.clear();
this.queue.length = 0; this.queue.length = 0;
this.logger.event('error', 'catalog-enrichment.persistence-failed', { this.logger.event('error', 'catalog-enrichment.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
} finally { } finally {
this.running = false; this.running = false;
@@ -596,53 +618,20 @@ function sourceInputs(
uid, uid,
kind, kind,
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ? ...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only. // eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
{ ticker: range?.tickers?.[uid] || range?.ticker } : ? { ticker: range?.tickers?.[uid] || range?.ticker }
{}), : {}),
...(['coupons', 'dividends'].includes(kind) ? ...(['coupons', 'dividends'].includes(kind)
{ ? {
rangeKey: range ? rangeKey: range
JSON.stringify([range.from, range.to]) : ? JSON.stringify([range.from, range.to])
String(new Date().getUTCFullYear()), : String(new Date().getUTCFullYear()),
} : }
{}), : {}),
})), })),
); );
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: (value as TbankShare).shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
const previousCloseLookbackDays = 20; const previousCloseLookbackDays = 20;
const previousCloseLookbackMs = const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY; previousCloseLookbackDays * MILLISECONDS_PER_DAY;
@@ -661,6 +650,40 @@ export type CatalogPayload = {
price?: null | TbankLastPrice; price?: null | TbankLastPrice;
tradingStatus?: null | string; tradingStatus?: null | string;
}; };
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
@@ -673,15 +696,13 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) { ) {
return 'quote'; return 'quote';
} }
if (kind === 'description' || kind === 'fundamentals') return 'company'; return 'stable';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
(input.kind === 'detail' || input.kind === 'bond-current') ? (input.kind === 'detail' || input.kind === 'bond-current')
`bond-metadata:${input.uid}` : ? `bond-metadata:${input.uid}`
catalogScope(input); : catalogScope(input);
} }
const quotationScale = 9; const quotationScale = 9;
@@ -1,5 +1,7 @@
import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30; export const catalogSearchLimit = 30;
export function selectCatalogInstruments<T extends TbankShare>( export function selectCatalogInstruments<T extends TbankShare>(
@@ -9,9 +11,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
): (T & { uid: string })[] { ): (T & { uid: string })[] {
const seen = new Set<string>(); const seen = new Set<string>();
const kind = const kind =
instrumentType === 'share' ? instrumentType === 'share'
'INSTRUMENT_TYPE_SHARE' : ? TbankInstrumentType.Share
'INSTRUMENT_TYPE_BOND'; : TbankInstrumentType.Bond;
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB'; const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found return found
.filter( .filter(
@@ -29,9 +31,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
a.uid.localeCompare(b.uid), a.uid.localeCompare(b.uid),
) )
.filter((instrument) => { .filter((instrument) => {
const key = instrument.isin?.trim() ? const key = instrument.isin?.trim()
`isin:${instrument.isin.trim().toUpperCase()}` : ? `isin:${instrument.isin.trim().toUpperCase()}`
`uid:${instrument.uid}`; : `uid:${instrument.uid}`;
if (seen.has(key)) return false; if (seen.has(key)) return false;
seen.add(key); seen.add(key);
return true; return true;
@@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
const MOSCOW_OFFSET_MS = 10_800_000; const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): null | string { export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds); const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ? return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() : ? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
null; : null;
} }
export function moscowDay(iso: string): string { export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS) return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
@@ -43,8 +43,8 @@ export function instrumentPosition(
currentValue, currentValue,
absoluteReturn, absoluteReturn,
averagePurchasePrice: averagePurchasePrice:
positive && totalCost ? positive && totalCost
{ ? {
currency: totalCost.currency, currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -53,15 +53,15 @@ export function instrumentPosition(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} : }
null, : null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null, returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({ accounts: positions.map((p) => ({
...p, ...p,
returnPercent: returnPercent:
decimalFromString(p.quantity).value > ZERO_QUANTITY ? decimalFromString(p.quantity).value > ZERO_QUANTITY
percent(p.absoluteReturn, cost(p)) : ? percent(p.absoluteReturn, cost(p))
null, : null,
})), })),
isStale: portfolio.isStale, isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt, observedAt: portfolio.lastSuccessfulAt,
@@ -69,8 +69,8 @@ export function instrumentPosition(
} }
function cost(position: PortfolioPositionDto): MoneyDto | null { function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice; const price = position.averagePurchasePrice;
return price ? return price
{ ? {
currency: price.currency, currency: price.currency,
value: formatDecimal( value: formatDecimal(
multiply( multiply(
@@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
decimalFromString(position.quantity), decimalFromString(position.quantity),
), ),
), ),
} : }
null; : null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency; const currency = values[0]?.currency;
@@ -37,18 +37,18 @@ export class BondCatalogService {
data: data:
result.data?.map((item) => { result.data?.map((item) => {
const short = toBond(item); const short = toBond(item);
const detail = item.detail ? const detail = item.detail
toBond({ ...item.detail, uid: item.uid }) : ? toBond({ ...item.detail, uid: item.uid })
short; : short;
const last = item.price; const last = item.price;
return { return {
...detail, ...detail,
classCode: detail.classCode ?? short.classCode, classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin, isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable, apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ? pricePercent: last?.price
formatDecimal(decimal(last.price)) : ? formatDecimal(decimal(last.price))
null, : null,
priceAt: timestamp(last?.time), priceAt: timestamp(last?.time),
}; };
}) ?? null, }) ?? null,
@@ -63,9 +63,9 @@ export class BondCatalogService {
const result = await this.discovery.detail(userId, 'bond', uid, observe); const result = await this.discovery.detail(userId, 'bond', uid, observe);
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...toBond(result.data), slug: result.data.slug } : ? { ...toBond(result.data), slug: result.data.slug }
null, : null,
}; };
} }
@@ -82,9 +82,9 @@ export class BondCatalogService {
); );
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...toBond(result.data), slug: result.data.slug } : ? { ...toBond(result.data), slug: result.data.slug }
null, : null,
}; };
} }
} }
@@ -7,10 +7,12 @@ import {
multiply, multiply,
} from '../../../../../common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
import { 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,
@@ -62,15 +64,15 @@ export class BondIncomeService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
bondMetrics(result.values, result.unavailable, result.observedAt) : ? bondMetrics(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
@@ -86,7 +88,7 @@ export class BondIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'bond', 'bond',
@@ -95,22 +97,22 @@ export class BondIncomeService {
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
const position = portfolio.data ? const position = portfolio.data
instrumentPosition(portfolio.data, uid, 'BOND') : ? instrumentPosition(portfolio.data, uid, 'BOND')
null; : null;
const projected = events ? const projected = events
bondCoupons( ? bondCoupons(
events, events,
position, position,
{ from, to, today }, { from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data, !portfolio.data,
) : )
undefined; : undefined;
const result = await this.forecasts.result({ const result = await this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -121,9 +123,13 @@ export class BondIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
return result.data || !projected ? if (result.data?.positionAt) {
result : result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
{ if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
data: bondCoupons( data: bondCoupons(
events ?? [], events ?? [],
null, null,
@@ -131,7 +137,7 @@ export class BondIncomeService {
projected.observedAt, projected.observedAt,
true, true,
), ),
meta: { ...result.meta, availability: 'partial' }, meta: { ...result.meta, completeness: 'incomplete' },
}; };
} }
} }
@@ -146,19 +152,19 @@ export function bondCoupons(
const { from, to, today } = window; const { from, to, today } = window;
const quantity = const quantity =
position?.accounts.length && position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
decimalFromString(position.quantity) : ? decimalFromString(position.quantity)
null; : null;
const coupons = events const coupons = events
.map((event) => { .map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perBond = money(event.payOneBond); const perBond = money(event.payOneBond);
const timing = !paymentDate ? const timing = !paymentDate
('undated' as const) : ? ('undated' as const)
paymentDate >= today ? : paymentDate >= today
('upcoming' as const) : ? ('upcoming' as const)
('past' as const); : ('past' as const);
return { return {
number: event.couponNumber ?? null, number: event.couponNumber ?? null,
paymentDate, paymentDate,
@@ -172,14 +178,14 @@ export function bondCoupons(
perBond && perBond &&
timing === 'upcoming' && timing === 'upcoming' &&
recordDate && recordDate &&
recordDate >= today ? recordDate >= today
{ ? {
currency: perBond.currency, currency: perBond.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
} : }
null, : null,
}; };
}) })
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? '')); .sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
@@ -225,8 +231,8 @@ export function bondMetrics(
clean && clean &&
coupon.currency === clean.currency && coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT ? decimalFromString(clean.value).value > ZERO_AMOUNT
formatDecimal( ? formatDecimal(
divide( divide(
multiply( multiply(
decimalFromString(coupon.value), decimalFromString(coupon.value),
@@ -238,14 +244,14 @@ export function bondMetrics(
), ),
COUPON_YIELD_DECIMAL_PLACES, COUPON_YIELD_DECIMAL_PLACES,
), ),
) : )
null; : null;
const couponType = const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ? next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
next.couponType : ? next.couponType
instrument.floatingCouponFlag === true ? : instrument.floatingCouponFlag === true
'COUPON_TYPE_FLOATING' : ? TbankCouponType.Floating
null; : null;
return { return {
creditRating, creditRating,
coupon, coupon,
@@ -48,9 +48,9 @@ export class BondMarketService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
bondQuote(result.values, result.unavailable, result.observedAt) : ? bondQuote(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -83,8 +83,8 @@ export function bondQuote(
const cleanPrice = const cleanPrice =
pricePercent !== null && pricePercent !== null &&
nominal && nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT ? decimalFromString(nominal.value).value > ZERO_AMOUNT
{ ? {
currency: nominal.currency, currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
@@ -96,11 +96,11 @@ export function bondQuote(
PRICE_DECIMAL_PLACES, PRICE_DECIMAL_PLACES,
), ),
), ),
} : }
null; : null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency ? cleanPrice && aci && aci.currency === cleanPrice.currency
{ ? {
currency: cleanPrice.currency, currency: cleanPrice.currency,
value: formatDecimal( value: formatDecimal(
add( add(
@@ -108,8 +108,8 @@ export function bondQuote(
decimalFromString(aci.value), decimalFromString(aci.value),
), ),
), ),
} : }
null; : null;
return { return {
pricePercent, pricePercent,
cleanPrice, cleanPrice,
@@ -118,9 +118,9 @@ export function bondQuote(
priceAt, priceAt,
tradingStatus, tradingStatus,
changePercentagePoints: changePercentagePoints:
last?.price && previous?.close ? last?.price && previous?.close
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) : ? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
null, : null,
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
unavailable, unavailable,
observedAt, observedAt,
@@ -123,9 +123,9 @@ describe('bond financial projections', () => {
{ {
couponDate: time('2026-10-10'), couponDate: time('2026-10-10'),
payOneBond: payOneBond:
scenario === 'unknown' ? scenario === 'unknown'
undefined : ? undefined
money('30', scenario === 'currency' ? 'usd' : 'rub'), : money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90, couponPeriod: scenario === 'period' ? 0 : 90,
}, },
], ],
@@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) { export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase(); const currency = value?.currency?.toUpperCase();
return value && currency ? return value && currency
{ currency, value: formatDecimal(decimal(value)) } : ? { currency, value: formatDecimal(decimal(value)) }
null; : null;
} }
@@ -119,13 +119,8 @@ export class BondController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@@ -38,9 +38,9 @@ export function candleGaps(
gaps = gaps.flatMap((gap) => { gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap]; if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [ return [
...(covered.from > gap.from ? ...(covered.from > gap.from
[{ from: gap.from, to: covered.from }] : ? [{ from: gap.from, to: covered.from }]
[]), : []),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []), ...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
]; ];
}); });
@@ -0,0 +1,6 @@
export enum InstrumentErrorCode {
BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE',
BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS',
StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE',
StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS',
}
@@ -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,
@@ -100,14 +94,14 @@ export class CandleRepository {
priority: nextPriority, priority: nextPriority,
intent: { intent: {
...previousIntent, ...previousIntent,
...(intent.force ? ...(intent.force
{ ? {
force: true, force: true,
...(intent.now && intent.now > previousNow ? ...(intent.now && intent.now > previousNow
{ now: intent.now } : ? { now: intent.now }
{}), : {}),
} : }
{}), : {}),
}, },
}, },
}); });
@@ -263,12 +257,12 @@ export class CandleRepository {
// Preserve coverage outside the requested reconciliation boundaries. // Preserve coverage outside the requested reconciliation boundaries.
for (const previous of old) { for (const previous of old) {
for (const part of [ for (const part of [
...(previous.from < range.from ? ...(previous.from < range.from
[{ from: previous.from, to: range.from }] : ? [{ from: previous.from, to: range.from }]
[]), : []),
...(previous.to > range.to ? ...(previous.to > range.to
[{ from: range.to, to: previous.to }] : ? [{ from: range.to, to: previous.to }]
[]), : []),
]) { ]) {
await tx.tbankCandleCoverage.create({ await tx.tbankCandleCoverage.create({
data: { ...previous, ...part }, data: { ...previous, ...part },
@@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '../../../../../generated/prisma/client';
import type { import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
TbankBond,
TbankShare,
TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -66,8 +63,8 @@ export class CatalogDiscoveryRepository {
read(input: CatalogQuery) { read(input: CatalogQuery) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const alias = input.exactTicker ? const alias = input.exactTicker
await tx.tbankInstrumentLookup.findUnique({ ? await tx.tbankInstrumentLookup.findUnique({
where: { where: {
provider_identifierType_identifier: { provider_identifierType_identifier: {
provider, provider,
@@ -75,17 +72,17 @@ export class CatalogDiscoveryRepository {
identifier: input.query, identifier: input.query,
}, },
}, },
}) : })
null; : null;
const discovery = input.exactTicker ? const discovery = input.exactTicker
alias ? ? alias
{ ? {
instrumentUids: alias.matchedUids, instrumentUids: alias.matchedUids,
fetchedAt: alias.fetchedAt, fetchedAt: alias.fetchedAt,
dataRevision: alias.dataRevision, dataRevision: alias.dataRevision,
} : }
null : : null
await tx.tbankCatalogDiscovery.findUnique({ : await tx.tbankCatalogDiscovery.findUnique({
where: { where: {
provider_instrumentType_query: { provider_instrumentType_query: {
provider, provider,
@@ -126,17 +123,29 @@ export class CatalogDiscoveryRepository {
} }
readSources(inputs: CatalogSource[]) { readSources(inputs: CatalogSource[]) {
// Share identity predicates across UIDs instead of preparing one OR per row.
const groups = new Map<string, CatalogSource[]>();
for (const input of inputs) {
const key = JSON.stringify([
input.kind,
sourceProvider(input),
input.kind === 'detail' ? input.instrumentType : (input.rangeKey ?? ''),
]);
const group = groups.get(key);
if (group) group.push(input);
else groups.set(key, [input]);
}
return this.prisma.tbankInstrumentSource.findMany({ return this.prisma.tbankInstrumentSource.findMany({
where: { where: {
provider, provider,
OR: inputs.map((input) => ({ OR: [...groups.values()].map(([input, ...rest]) => ({
instrumentUid: input.uid, instrumentUid: { in: [input, ...rest].map((entry) => entry.uid) },
kind: input.kind, kind: input.kind,
source: sourceProvider(input), source: sourceProvider(input),
rangeKey: rangeKey:
input.kind === 'detail' ? input.kind === 'detail'
input.instrumentType : ? input.instrumentType
(input.rangeKey ?? ''), : (input.rangeKey ?? ''),
})), })),
}, },
}); });
@@ -232,9 +241,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previousAlias?.matchedUids, previousAlias?.matchedUids,
matchedUids, matchedUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -246,9 +255,9 @@ export class CatalogDiscoveryRepository {
source: sourceProvider(input), source: sourceProvider(input),
kind: input.kind, kind: input.kind,
rangeKey: rangeKey:
input.kind === 'detail' ? input.kind === 'detail'
input.instrumentType : ? input.instrumentType
(input.rangeKey ?? ''), : (input.rangeKey ?? ''),
}; };
const previous = await tx.tbankInstrumentSource.findUnique({ const previous = await tx.tbankInstrumentSource.findUnique({
where: { provider_instrumentUid_source_kind_rangeKey: key }, where: { provider_instrumentUid_source_kind_rangeKey: key },
@@ -359,9 +368,9 @@ export class CatalogDiscoveryRepository {
instrumentKind: instrument.instrumentKind, instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency, currency: instrument.currency,
brand: instrument.brand ? brand: instrument.brand
{ logoName: instrument.brand.logoName } : ? { logoName: instrument.brand.logoName }
undefined, : undefined,
}), }),
) as Prisma.InputJsonObject; ) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({ await tx.tbankInstrument.upsert({
@@ -418,9 +427,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.matchedUids, previous?.matchedUids,
instrumentUids, instrumentUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -444,9 +453,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual( increment: isDeepStrictEqual(
previous?.instrumentUids, previous?.instrumentUids,
instrumentUids, instrumentUids,
) ? )
UNCHANGED_REVISION_INCREMENT : ? UNCHANGED_REVISION_INCREMENT
REVISION_INCREMENT, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -462,7 +471,7 @@ export class CatalogDiscoveryRepository {
state: 'running', state: 'running',
connection: { invalidToken: true }, connection: { invalidToken: true },
}, },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
await tx.tbankSyncWork.updateMany({ await tx.tbankSyncWork.updateMany({
where: { where: {
@@ -472,7 +481,7 @@ export class CatalogDiscoveryRepository {
}, },
data: { data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE', errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
}); });
}); });
@@ -480,9 +489,9 @@ export class CatalogDiscoveryRepository {
} }
function sourceProvider(input: CatalogSource) { function sourceProvider(input: CatalogSource) {
return input.kind === 'yield' || input.kind === 'bond-events' ? return input.kind === 'yield' || input.kind === 'bond-events'
'moex' : ? 'moex'
input.kind === 'rating' ? : input.kind === 'rating'
'ncr' : ? 'ncr'
'tbank'; : 'tbank';
} }
@@ -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';
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
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 { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockCatalogService } from './stocks/application/stock-catalog.service';
import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service'; import { StockMarketService } from './stocks/application/stock-market.service';
import { StockScreenerService } from './stocks/application/stock-screener.service';
import { StockController } from './stocks/presentation/stock.controller'; import { StockController } from './stocks/presentation/stock.controller';
@Module({ @Module({
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
controllers: [StockController, BondController], controllers: [StockController, BondController],
exports: [StockIncomeService, BondIncomeService], exports: [StockIncomeService, BondIncomeService],
providers: [ providers: [
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
BondIncomeService, BondIncomeService,
BondMarketService, BondMarketService,
GetStockCompanyUseCase, GetStockCompanyUseCase,
StockIncomeService, StockIncomeService,
StockMarketService, StockMarketService,
StockCatalogService, StockCatalogService,
StockScreenerService,
BondCatalogService, BondCatalogService,
], ],
}) })
@@ -23,8 +23,8 @@ export class GetStockCompanyUseCase {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
{ ? {
description: result.values.description ?? null, description: result.values.description ?? null,
metrics: metrics( metrics: metrics(
result.values.fundamentals ?? null, result.values.fundamentals ?? null,
@@ -32,8 +32,8 @@ export class GetStockCompanyUseCase {
), ),
unavailable: result.unavailable, unavailable: result.unavailable,
observedAt: result.observedAt, observedAt: result.observedAt,
} : }
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -51,22 +51,22 @@ export function metrics(
][] = [ ][] = [
['marketCapitalization', 'money', null], ['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'], ['peRatioTtm', 'ratio', 'TTM'],
...(bank ? ...(bank
([ ? ([
['priceToBookTtm', 'ratio', 'TTM'], ['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null], ['roe', 'percent', null],
] as typeof selection) : ] as typeof selection)
([ : ([
['revenueTtm', 'money', 'TTM'], ['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'], ['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)), ] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'], ['netIncomeTtm', 'money', 'TTM'],
...(!bank ? ...(!bank
([ ? ([
['totalDebtMrq', 'money', 'MRQ'], ['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null], ['netDebtToEbitda', 'ratio', null],
] as typeof selection) : ] as typeof selection)
[]), : []),
]; ];
return selection.flatMap(([key, unit, period]) => { return selection.flatMap(([key, unit, period]) => {
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above. // eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
@@ -74,8 +74,8 @@ export function metrics(
const currency = values.currency?.toUpperCase() || null; const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' && return typeof value === 'number' &&
Number.isFinite(value) && Number.isFinite(value) &&
(unit !== 'money' || currency) ? (unit !== 'money' || currency)
[ ? [
{ {
key, key,
value, value,
@@ -83,7 +83,7 @@ export function metrics(
period, period,
currency: unit === 'money' ? currency : null, currency: unit === 'money' ? currency : null,
}, },
] : ]
[]; : [];
}); });
} }
@@ -34,9 +34,9 @@ export class StockCatalogService {
data: data:
result.data?.map((item) => { result.data?.map((item) => {
const short = stock(item); const short = stock(item);
const detail = item.detail ? const detail = item.detail
stock({ ...item.detail, uid: item.uid }) : ? stock({ ...item.detail, uid: item.uid })
short; : short;
const last = item.price; const last = item.price;
return { return {
...detail, ...detail,
@@ -44,12 +44,12 @@ export class StockCatalogService {
isin: detail.isin ?? short.isin, isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable, apiTradeAvailable: short.apiTradeAvailable,
price: price:
last?.price && detail.currency ? last?.price && detail.currency
{ ? {
currency: detail.currency, currency: detail.currency,
value: formatDecimal(decimal(last.price)), value: formatDecimal(decimal(last.price)),
} : }
null, : null,
priceAt: timestamp(last?.time), priceAt: timestamp(last?.time),
}; };
}) ?? null, }) ?? null,
@@ -64,9 +64,9 @@ export class StockCatalogService {
const result = await this.discovery.detail(userId, 'share', uid, observe); const result = await this.discovery.detail(userId, 'share', uid, observe);
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...stock(result.data), slug: result.data.slug } : ? { ...stock(result.data), slug: result.data.slug }
null, : null,
}; };
} }
@@ -83,9 +83,9 @@ export class StockCatalogService {
); );
return { return {
...result, ...result,
data: result.data ? data: result.data
{ ...stock(result.data), slug: result.data.slug } : ? { ...stock(result.data), slug: result.data.slug }
null, : null,
}; };
} }
} }
@@ -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,
@@ -39,8 +40,8 @@ export class StockIncomeService {
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
) {} ) {}
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
@@ -56,7 +57,7 @@ export class StockIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'share', 'share',
@@ -65,22 +66,22 @@ export class StockIncomeService {
['detail', 'dividends'], ['detail', 'dividends'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.dividends; const events = sources[1].payload?.dividends;
const position = portfolio.data ? const position = portfolio.data
instrumentPosition(portfolio.data, uid, 'STOCK') : ? instrumentPosition(portfolio.data, uid, 'STOCK')
null; : null;
const projected = events ? const projected = events
stockDividends( ? stockDividends(
events, events,
position, position,
{ from, to, today }, { from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(), sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data, !portfolio.data,
) : )
undefined; : undefined;
const result = await this.forecasts.result({ const result = await this.forecasts.result({
userId, userId,
revision: connection.revision, revision: connection.revision,
@@ -91,9 +92,13 @@ export class StockIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
return result.data || !projected ? if (result.data?.positionAt) {
result : result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
{ if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
data: stockDividends( data: stockDividends(
events ?? [], events ?? [],
null, null,
@@ -101,7 +106,7 @@ export class StockIncomeService {
projected.observedAt, projected.observedAt,
true, true,
), ),
meta: { ...result.meta, availability: 'partial' }, meta: { ...result.meta, completeness: 'incomplete' },
}; };
} }
} }
@@ -117,9 +122,9 @@ export function stockDividends(
const quantity = const quantity =
position && position &&
position.accounts.length && position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ? position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
decimalFromString(position.quantity) : ? decimalFromString(position.quantity)
null; : null;
const dividends: StockDividendDto[] = events const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled') .filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => { .map((event) => {
@@ -128,29 +133,29 @@ export function stockDividends(
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null; const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase(); const currency = event.dividendNet?.currency?.toUpperCase();
const perShare = const perShare =
currency && event.dividendNet ? currency && event.dividendNet
{ currency, value: formatDecimal(decimal(event.dividendNet)) } : ? { currency, value: formatDecimal(decimal(event.dividendNet)) }
null; : null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding. // A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate; const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate ? const timing: StockDividendDto['timing'] = eventDate
eventDate >= today ? ? eventDate >= today
'upcoming' : ? 'upcoming'
'past' : : 'past'
'undated'; : 'undated';
const estimatedAmount = const estimatedAmount =
quantity && quantity &&
perShare && perShare &&
recordDate && recordDate &&
recordDate >= today && recordDate >= today &&
timing === 'upcoming' ? timing === 'upcoming'
{ ? {
currency: perShare.currency, currency: perShare.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)), multiply(quantity, decimalFromString(perShare.value)),
), ),
} : }
null; : null;
return { return {
perShare, perShare,
recordDate, recordDate,
@@ -44,9 +44,9 @@ export class StockMarketService {
observe, observe,
); );
return { return {
data: result.values.instrument ? data: result.values.instrument
stockQuote(result.values, result.unavailable, result.observedAt) : ? stockQuote(result.values, result.unavailable, result.observedAt)
null, : null,
meta: result.meta, meta: result.meta,
}; };
} }
@@ -75,20 +75,20 @@ export function stockQuote(
const priceAt = timestamp(last?.time); const priceAt = timestamp(last?.time);
const currency = share?.currency?.toUpperCase(); const currency = share?.currency?.toUpperCase();
const price = const price =
last?.price && currency ? last?.price && currency
{ currency, value: formatDecimal(decimal(last.price)) } : ? { currency, value: formatDecimal(decimal(last.price)) }
null; : null;
const previousPrice = const previousPrice =
previous?.close && currency ? previous?.close && currency
{ currency, value: formatDecimal(decimal(previous.close)) } : ? { currency, value: formatDecimal(decimal(previous.close)) }
null; : null;
const difference = const difference =
price && previousPrice ? price && previousPrice
subtract( ? subtract(
decimalFromString(price.value), decimalFromString(price.value),
decimalFromString(previousPrice.value), decimalFromString(previousPrice.value),
) : )
null; : null;
return { return {
price, price,
priceAt, priceAt,
@@ -96,21 +96,21 @@ export function stockQuote(
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null, previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
previousClose: previousPrice, previousClose: previousPrice,
change: change:
difference && currency ? difference && currency
{ currency, value: formatDecimal(difference) } : ? { currency, value: formatDecimal(difference) }
null, : null,
changePercent: changePercent:
difference && difference &&
previousPrice && previousPrice &&
decimalFromString(previousPrice.value).value > ZERO_AMOUNT ? decimalFromString(previousPrice.value).value > ZERO_AMOUNT
formatDecimal( ? formatDecimal(
divide( divide(
multiply(difference, decimalFromString('100')), multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value), decimalFromString(previousPrice.value),
CHANGE_PERCENT_DECIMAL_PLACES, CHANGE_PERCENT_DECIMAL_PLACES,
), ),
) : )
null, : null,
unavailable, unavailable,
observedAt, observedAt,
}; };
@@ -0,0 +1,106 @@
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { StockScreenerService } from './stock-screener.service';
const token = 'secret-token';
const revision = 'revision-1';
const pageSize = 30;
function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
return {
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
currency: 'RUB',
lot: 1,
name: `Share ${index}`,
ticker: `T${String(index).padStart(2, '0')}`,
uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`,
...overrides,
};
}
describe('StockScreenerService', () => {
const access = { current: jest.fn(), local: jest.fn() };
const client = { getAllShares: jest.fn() };
const logger = { event: jest.fn() };
let service: StockScreenerService;
beforeEach(() => {
jest.clearAllMocks();
access.current.mockResolvedValue({ revision, token });
access.local.mockResolvedValue({ revision });
client.getAllShares.mockResolvedValue(
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
);
service = new StockScreenerService(access, client as never, logger as never);
});
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
const first = await service.search('user-1', {});
const second = await service.page('user-1', first.searchId, 2);
expect(first.items).toHaveLength(pageSize);
expect(first.hasNext).toBe(true);
expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId });
expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]);
expect(client.getAllShares).toHaveBeenCalledTimes(1);
});
it('creates a fresh result for every search and preserves a successful empty result', async () => {
client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]);
const first = await service.search('user-1', { sector: 'finance' });
const second = await service.search('user-1', { sector: 'finance' });
expect(first).toMatchObject({ hasNext: false, items: [], page: 1 });
expect(second).toMatchObject({ hasNext: false, items: [], page: 1 });
expect(second.searchId).not.toBe(first.searchId);
expect(client.getAllShares).toHaveBeenCalledTimes(2);
});
it('does not store a result when the catalog source fails', async () => {
client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => {
const first = await service.search('user-1', {});
access.local.mockResolvedValueOnce({ revision: 'revision-2' });
await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 });
expect(client.getAllShares).toHaveBeenCalledTimes(1);
});
it('null-normalizes optional source fields and rejects malformed required fields', async () => {
client.getAllShares.mockResolvedValueOnce([
share(1, {
countryOfRisk: undefined,
currency: undefined,
isin: undefined,
realExchange: undefined,
sector: undefined,
shareType: undefined,
ticker: undefined,
}),
]);
const result = await service.search('user-1', {});
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('preserves the catalog logo name in screener results', async () => {
client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]);
const result = await service.search('user-1', {});
expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]);
});
});
@@ -0,0 +1,140 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import {
selectStockScreenerInstruments,
type StockScreenerConditions,
} from '../domain/stock-screener-selection.rules';
const MAX_SNAPSHOTS = 100;
const PAGE_SIZE = 30;
const MIN_PAGE = 1;
const MINIMUM_SNAPSHOT_COUNT = 1;
const SECONDS_PER_MINUTE = 60;
const SNAPSHOT_TTL_MINUTES = 5;
const MILLISECONDS_PER_SECOND = 1_000;
const SNAPSHOT_TTL_MS = SNAPSHOT_TTL_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type StockScreenerItem = {
countryOfRisk: null | string;
currency: null | string;
instrumentUid: string;
isin: null | string;
logoName: null | string;
lot: null | number;
name: string;
realExchange: null | string;
sector: null | string;
shareType: null | string;
ticker: null | string;
};
type StockScreenerPage = {
asOf: string;
hasNext: boolean;
items: StockScreenerItem[];
page: number;
searchId: string;
};
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
type TbankAccess = {
current(userId: string): Promise<{ revision: string; token: string }>;
local(userId: string): Promise<{ revision: string }>;
};
@Injectable()
export class StockScreenerService {
private readonly snapshots = new Map<string, Snapshot>();
constructor(
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
private readonly client: TbankClient,
private readonly logger: StructuredLogger,
) {}
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
const connection = await this.access.current(userId);
const instruments = await this.client.getAllShares(connection.token);
const asOf = new Date().toISOString();
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
.instruments.map((instrument) => this.item(instrument));
const searchId = randomUUID();
this.evict();
this.snapshots.set(searchId, {
asOf,
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
items: selected,
revision: connection.revision,
userId,
});
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
return this.result(searchId, this.snapshots.get(searchId) as Snapshot, MIN_PAGE);
}
async page(userId: string, searchId: string, page: number): Promise<StockScreenerPage> {
const snapshot = this.snapshots.get(searchId);
if (!snapshot || snapshot.expiresAt <= Date.now()) {
this.snapshots.delete(searchId);
this.logger.event('info', 'stock.screener.result.missing', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
if (snapshot.userId !== userId) {
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
const current = await this.access.local(userId);
if (snapshot.revision !== current.revision) {
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
return this.result(searchId, snapshot, page);
}
private result(searchId: string, snapshot: Snapshot, page: number): StockScreenerPage {
const start = (page - MIN_PAGE) * PAGE_SIZE;
if (!Number.isInteger(page) || page < MIN_PAGE || (start >= snapshot.items.length && !(page === MIN_PAGE && !snapshot.items.length))) {
throw new BadRequestException();
}
const items = snapshot.items.slice(start, start + PAGE_SIZE);
return { asOf: snapshot.asOf, hasNext: start + PAGE_SIZE < snapshot.items.length, items, page, searchId };
}
private item(instrument: TbankShare): StockScreenerItem {
if (!instrument.uid || !UUID_PATTERN.test(instrument.uid) || !instrument.name?.trim()) {
throw new ServiceUnavailableException();
}
return {
countryOfRisk: instrument.countryOfRisk ?? null,
currency: instrument.currency ?? null,
instrumentUid: instrument.uid,
isin: instrument.isin ?? null,
lot: instrument.lot ?? null,
logoName: instrument.brand?.logoName?.trim() || null,
name: instrument.name,
realExchange: instrument.realExchange ?? null,
sector: instrument.sector ?? null,
shareType: instrument.shareType ?? null,
ticker: instrument.ticker ?? null,
};
}
private evict(): void {
for (const [searchId, snapshot] of this.snapshots) {
if (snapshot.expiresAt <= Date.now()) this.snapshots.delete(searchId);
}
while (this.snapshots.size >= MAX_SNAPSHOTS) {
const searchId = this.snapshots.keys().next().value;
if (searchId && this.snapshots.size >= MINIMUM_SNAPSHOT_COUNT) this.snapshots.delete(searchId);
}
}
}

Some files were not shown because too many files have changed in this diff Show More