58 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 9cc9fbdcff refactor: replace ignored numeric literals with constants
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 31s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m56s
2026-09-28 11:24:59 +03:00
ksv741 9b3558f96c chore: strengthen backend eslint profile 2026-09-28 11:04:18 +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
224 changed files with 9257 additions and 5324 deletions
+24 -14
View File
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
request context и ограничивает поля событий. Jest unit tests, изолированные
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями 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` — отдельные
публичные входы для protobuf conversion и interval rules: импорт расчётов
не загружает DI/Prisma.
публичные входы для protobuf conversion, protocol/error vocabulary и interval
rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются
@@ -235,8 +236,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
Общий выбор symbolic values и числовых policies —
[Domain values](conventions/engineering.md#domain-values).
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает
`any` нормой domain/application model. `declare` допустим для заполняемых извне
`unknown` + runtime narrowing; ESLint запрещает explicit `any`
в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
DTO fields, не для сокрытия неинициализированного owned state.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
@@ -246,11 +247,17 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм,
индексов, default values, enum/type declarations и readonly properties заданы
в ESLint config. Test fixtures/expected values остаются inline, остальные lint
правила продолжают действовать. Numeric policy получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента.
отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
значения, проверяемые правилом, получают предметные имена у владельца.
Исключения для индексов, default values, enum/type declarations и readonly
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
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
@@ -260,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
## Проверка
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
controllers делегируют application, use cases не импортируют Prisma. Чистые
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
+31 -13
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values
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 не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные 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.
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
чтения и `git diff --check`; application build ради текста не нужен.
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный lint не заменяет semantic/security review.
| Когда | Что проверить |
| --- | --- |
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## 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.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+7 -1
View File
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
Преобразование provider records происходит на boundary: например,
`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 на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
@@ -41,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе.
+3 -1
View File
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
+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:
push:
branches: ['**']
branches: [develop, main]
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:
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:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй 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
## [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)
### Bug Fixes
+68 -10
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
выполните обе команды.
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -74,17 +77,38 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на 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 записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями, блокирует любой runtime advisory через
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются.
Имена 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 требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
@@ -105,6 +129,38 @@ Required checks настраиваются отдельно в Gitea: налич
runtime-валидацию. В обычном коде нужны инициализация или сужение типа
после проверки значения.
## ESLint
`npm run lint` проверяет весь authored TypeScript и JavaScript, включая scripts
и configs. `npm run lint:fix` (также `npm run format`) применяет безопасные
автоисправления. Форматирование принадлежит ESLint Stylistic: два пробела,
одинарные кавычки и точки с запятой; Prettier не используется.
Профиль: ESLint recommended, typescript-eslint recommendedTypeChecked,
Node, NestJS typed, Jest, import-x с TypeScript resolver, SonarJS, RegExp,
Security, Perfectionist recommended-natural и Stylistic customize.
NestJS проверяется в production TS, Jest — в specs/e2e; generated Prisma,
dist и coverage исключены. DI imports сохраняют runtime metadata.
Исключения конфигурации: private backend не публикуется в npm;
TS imports разрешает import-x вместо Node resolver; IsIn проверяет domain
unions. Автосортировка объектов, классов, модулей и декораторов отключена,
поскольку порядок может влиять на runtime. Остальные точечные исключения
объясняют false positives непосредственно в коде.
`eslint-suppressions.json` — штатный ESLint baseline только для существующих
SonarJS cognitive-complexity, no-nested-conditional и no-nested-template-literals.
Это отложенный refactor, а не исправленные нарушения. Baseline ограничен
файлами и количеством сообщений; новые файлы и превышение количества блокируют
lint. Он не защищает от усложнения уже учтённой функции при прежнем количестве
сообщений — это остаётся предметом review. При сокращении долга обновляйте
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
Рост baseline — изменение quality gate: он требует отдельного обоснования
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
@@ -239,9 +295,11 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
+10 -2
View File
@@ -1,4 +1,12 @@
module.exports = {
extends: ["@commitlint/config-conventional"],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] },
extends: ['@commitlint/config-conventional'],
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'],
},
};
+175
View File
@@ -0,0 +1,175 @@
{
"src/common/decimal.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/integrations/tbank/tbank.client.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/application/bank-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/income/income-period.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 4
},
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/bonds/application/bond-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/bonds/application/bond.spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/infrastructure/candle.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
}
},
"src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/instruments/stocks/application/stock-income.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": {
"count": 9
}
},
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/portfolio/application/forecast.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
}
},
"test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": {
"count": 1
}
},
"test/sqlite-regression.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/sqlite.cjs": {
"sonarjs/no-nested-conditional": {
"count": 2
}
},
"test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
}
}
}
+185 -57
View File
@@ -1,59 +1,94 @@
// @ts-check
import nest from '@darraghor/eslint-plugin-nestjs-typed';
import eslint from '@eslint/js';
import eslintPluginPrettierRecommended from 'eslint-plugin-prettier/recommended';
import stylistic from '@stylistic/eslint-plugin';
import { createTypeScriptImportResolver } from 'eslint-import-resolver-typescript';
import { importX } from 'eslint-plugin-import-x';
import jest from 'eslint-plugin-jest';
import node from 'eslint-plugin-n';
import perfectionist from 'eslint-plugin-perfectionist';
import regexp from 'eslint-plugin-regexp';
import security from 'eslint-plugin-security';
import sonarjs from 'eslint-plugin-sonarjs';
import { defineConfig, globalIgnores } from 'eslint/config';
import globals from 'globals';
import tseslint from 'typescript-eslint';
export default tseslint.config(
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
];
export default defineConfig(
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
{
ignores: ['eslint.config.mjs', 'dist/', 'generated/'],
},
eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked,
eslintPluginPrettierRecommended,
{
languageOptions: {
globals: {
...globals.node,
...globals.jest,
},
sourceType: 'commonjs',
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
files: ['**/*.{ts,js,cjs,mjs}'],
extends: [
eslint.configs.recommended,
node.configs['flat/recommended'],
importX.flatConfigs.recommended,
sonarjs.configs.recommended,
regexp.configs['flat/recommended'],
security.configs.recommended,
perfectionist.configs['recommended-natural'],
stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
],
languageOptions: { globals: globals.node },
linterOptions: { reportUnusedDisableDirectives: 'error' },
settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()],
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
},
rules: {
'curly': ['error', 'multi-line'],
// This private application is deployed as a whole, never published to npm.
'n/no-unpublished-import': 'off',
'n/no-unpublished-require': 'off',
// Decorator execution and object insertion order can be observable at runtime.
'perfectionist/sort-decorators': 'off',
'perfectionist/sort-objects': 'off',
'perfectionist/sort-modules': 'off',
'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': [
'error',
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
},
},
{
files: ['**/*.mjs'],
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'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
languageOptions: {
sourceType: 'module',
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
rules: {
// TypeScript resolves source imports; import-x checks them with the TS resolver.
'n/no-missing-import': 'off',
'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [
'error',
{
ignore: [
-60,
-24,
-10,
-2,
-1,
0,
1,
2,
10,
24,
60,
'-60n',
'-24n',
'-10n',
'-2n',
'-1n',
'0n',
'1n',
'2n',
'10n',
'24n',
'60n',
],
ignoreArrayIndexes: true,
ignoreDefaultValues: true,
ignoreEnums: true,
@@ -63,24 +98,117 @@ export default tseslint.config(
enforceConst: true,
},
],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [
'error',
{
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-floating-promises': 'warn',
'@typescript-eslint/no-unsafe-argument': 'warn',
'prettier/prettier': ['error', { endOfLine: 'auto' }],
'no-restricted-syntax': ['error', {
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
}],
},
},
{
files: ['**/*.spec.ts', 'test/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' },
files: ['src/**/*.ts'],
ignores: tests,
extends: [nest.configs.flatRecommended],
rules: {
// Domain string unions are validated with IsIn rather than runtime TS enums.
'@darraghor/nestjs-typed/validated-non-primitive-property-needs-type-decorator': ['error', { additionalTypeDecorators: ['IsIn'] }],
},
},
{
files: tests,
extends: [jest.configs['flat/recommended']],
rules: {
'@typescript-eslint/no-magic-numbers': 'off',
'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',
},
},
);
+757 -383
View File
File diff suppressed because it is too large Load Diff
+28 -14
View File
@@ -1,6 +1,6 @@
{
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.1.0",
"description": "",
"author": "",
"private": true,
@@ -11,14 +11,14 @@
"scripts": {
"build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"",
"format": "npm run lint:fix",
"start": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js",
"lint": "npm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix",
"lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint . --fix --max-warnings=0",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -26,17 +26,19 @@
"test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "npm run lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
"release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci",
"commitlint": "commitlint"
"commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs"
},
"dependencies": {
"@grpc/grpc-js": "1.14.4",
"@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0",
@@ -53,6 +55,7 @@
"class-transformer": "0.5.1",
"class-validator": "0.15.1",
"cookie-parser": "1.4.7",
"dotenv": "17.4.2",
"lru-cache": "11.5.2",
"nestjs-pino": "5.1.0",
"passport": "0.7.0",
@@ -65,12 +68,13 @@
"devDependencies": {
"@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.2",
"@eslint/eslintrc": "3.3.7",
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
"@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0",
"@nestjs/schematics": "12.0.1",
"@nestjs/testing": "12.0.1",
"@release-it/conventional-changelog": "12.0.0",
"@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6",
@@ -79,12 +83,17 @@
"@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0",
"eslint": "10.10.0",
"eslint-config-prettier": "10.1.8",
"eslint-plugin-prettier": "5.5.6",
"eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest": "29.16.6",
"eslint-plugin-n": "18.4.0",
"eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.1",
"globals": "17.12.0",
"jest": "30.5.1",
"prettier": "3.9.6",
"release-it": "21.0.2",
"source-map-support": "0.5.21",
"supertest": "7.2.2",
@@ -93,15 +102,20 @@
"ts-node": "10.9.2",
"tsconfig-paths": "4.2.0",
"typescript": "6.0.3",
"typescript-eslint": "8.70.0"
"typescript-eslint": "8.70.1"
},
"overrides": {
"proxy-addr": "2.0.8",
"@nestjs/platform-express": {
"multer": "2.3.0"
"multer": "2.4.0"
},
"@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2"
},
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
},
"prisma": {
"mysql2": "3.24.4"
}
@@ -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[]
sessions Session[]
tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
@@ -69,6 +70,34 @@ model TbankPortfolio {
@@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 {
userId String
revision String
+10 -9
View File
@@ -1,10 +1,11 @@
const assert = require("node:assert/strict");
const { execFileSync } = require("node:child_process");
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
const assert = require('node:assert/strict');
const { execFileSync } = require('node:child_process');
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release");
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname")
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? "0.0.0",
"Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.");
const { version } = require('../package.json');
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
'Version is already prepared or release tags are missing. Fetch tags; finish the existing release before preparing another.');
+1
View File
@@ -15,6 +15,7 @@ Object.assign(process.env, {
});
const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
+4 -37
View File
@@ -1,10 +1,8 @@
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { ConfigModule } from '@nestjs/config';
import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation';
import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -13,6 +11,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
import { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module';
@@ -22,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true,
validate: validateEnv,
}),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule,
PrismaModule,
AuthModule,
+45 -23
View File
@@ -1,12 +1,30 @@
export type Decimal = { value: bigint; scale: number };
export const DECIMAL_RADIX = 10n;
const SIGN_LENGTH = 1;
const NEGATIVE_SIGN = -1n;
const POSITIVE_SIGN = 1n;
const ZERO_UNITS = 0n;
const EQUAL = 0;
const GREATER_THAN = 1;
const LESS_THAN = -1;
const INTEGER_SCALE = 0;
const MIN_WHOLE_DIGITS = 1;
const DIGITS_START = 0;
const PREVIOUS_DIGIT_OFFSET = 1;
const DIGIT_STEP = 1;
const ROUNDING_DIVISOR = 2n;
export type Decimal = { scale: number; value: bigint };
export function decimalFromString(value: string): Decimal {
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value))
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
if (!/^-?(?:0|[1-9]\d*)(?:\.\d+)?$/.test(value)) {
throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.');
const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`),
value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
scale: fraction.length,
};
}
@@ -15,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale);
return {
value:
left.value * 10n ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale),
left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
scale,
};
}
@@ -30,33 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
}
export function isZero(value: Decimal): boolean {
return value.value === 0n;
return value.value === ZERO_UNITS;
}
export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value;
return difference === 0n ? 0 : difference > 0n ? 1 : -1;
return difference === ZERO_UNITS ? EQUAL : difference > ZERO_UNITS ? GREATER_THAN : LESS_THAN;
}
export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0';
const sign = value < 0n ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`;
const digits = (value < 0n ? -value : value)
if (value === ZERO_UNITS) return '0';
const sign = value < ZERO_UNITS ? '-' : '';
if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
const digits = (value < ZERO_UNITS ? -value : value)
.toString()
.padStart(scale + 1, '0');
const whole = digits.slice(0, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, '');
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`;
.padStart(scale + MIN_WHOLE_DIGITS, '0');
const whole = digits.slice(DIGITS_START, -scale) || '0';
const rawFraction = digits.slice(-scale);
let fractionEnd = rawFraction.length;
while (rawFraction.charAt(fractionEnd - PREVIOUS_DIGIT_OFFSET) === '0') fractionEnd -= DIGIT_STEP;
const fraction = rawFraction.slice(DIGITS_START, fractionEnd);
const fractionalPart = fraction ? '.' + fraction : '';
return `${sign}${whole}${fractionalPart}`;
}
// Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n;
const n = numerator < 0n ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale };
if (right.value === ZERO_UNITS) throw new Error('Division by zero');
const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
const n = numerator < ZERO_UNITS ? -numerator : numerator;
const d = denominator < ZERO_UNITS ? -denominator : denominator;
return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
}
@@ -1,20 +1,23 @@
import { HttpStatus, applyDecorators } from '@nestjs/common';
import { applyDecorators, HttpStatus } from '@nestjs/common';
import {
ApiExtraModels,
ApiProperty,
ApiPropertyOptional,
ApiResponse,
getSchemaPath,
} from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto {
@ApiProperty({ example: 401 })
declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' })
@ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string;
@ApiProperty({
required: false,
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
@ApiPropertyOptional({
type: 'array',
items: {
type: 'object',
@@ -23,7 +26,7 @@ export class ProblemDetailsDto {
},
example: [{ pointer: '#/email', detail: 'must be a valid email address' }],
})
errors?: { pointer: string; detail: string }[];
errors?: { detail: string; pointer: string }[];
}
function problemResponse(status: number, description: string) {
@@ -38,7 +41,7 @@ function problemResponse(status: number, description: string) {
});
}
export function ApiProblemResponses(): MethodDecorator & ClassDecorator {
export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators(
ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import {
CreateBankCardDto,
@@ -6,7 +7,8 @@ import {
} from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => {
const pipe = new ValidationPipe({ transform: true, whitelist: true });
const pipe = new ValidationPipe({
forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto };
+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',
}
@@ -1,4 +1,5 @@
import { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => {
+15 -10
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import {
ArgumentsHost,
Catch,
@@ -6,7 +8,8 @@ import {
HttpStatus,
ValidationError,
} from '@nestjs/common';
import type { Response } from 'express';
import { HttpErrorCode } from '../error-code';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
@@ -24,9 +27,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
(payload as { message?: unknown })?.message,
);
const code = isValidation
? 'VALIDATION_ERROR'
? HttpErrorCode.ValidationError
: this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code;
response.locals ??= {};
response.locals.publicErrorCode = code;
response
.status(status)
.type('application/problem+json')
@@ -49,20 +53,21 @@ export class HttpExceptionFilter implements ExceptionFilter {
if (typeof code === 'string') return code;
}
return (
// 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',
401: 'UNAUTHORIZED',
404: 'NOT_FOUND',
415: 'UNSUPPORTED_MEDIA_TYPE',
500: 'INTERNAL_SERVER_ERROR',
}[status] ?? 'INTERNAL_SERVER_ERROR'
400: HttpErrorCode.BadRequest,
401: HttpErrorCode.Unauthorized,
404: HttpErrorCode.NotFound,
415: HttpErrorCode.UnsupportedMediaType,
500: HttpErrorCode.InternalServerError,
}[status] ?? HttpErrorCode.InternalServerError
);
}
private validationErrors(
errors: ValidationError[],
prefix = '#',
): { pointer: string; detail: string }[] {
): { detail: string; pointer: string }[] {
return errors.flatMap((error) => {
if (!error || typeof error.property !== 'string') return [];
const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
+59 -8
View File
@@ -1,15 +1,66 @@
import type { Request, Response } from 'express';
import type { Options } from 'pino-http';
import { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service';
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';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global()
@Module({
providers: [
RequestContextService,
RequestLoggingMiddleware,
StructuredLogger,
],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
imports: [LoggerModule.forRootAsync<Request, Response>({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
})],
providers: [StructuredLogger],
exports: [StructuredLogger],
})
export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
requestId: string;
clientRequestId?: string;
userId?: string;
brokerageAccountId?: 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,53 +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: {
method: string;
baseUrl: string;
route: { path: string };
header: jest.Mock<string>;
id?: 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,65 +0,0 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, NestMiddleware } from '@nestjs/common';
import type { NextFunction, Request, Response } from 'express';
import { randomUUID } from 'node:crypto';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
constructor(
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
use(request: Request, response: Response, next: NextFunction): void {
const incoming = request.header('x-request-id');
const clientRequestId =
incoming && uuid.test(incoming) ? incoming : undefined;
const existingRequestId = (request as Request & { id?: unknown }).id;
const requestId =
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
(request as unknown as { id: string }).id = requestId;
const startedAt = performance.now();
response.setHeader('X-Request-ID', requestId);
response.on('finish', () => {
const routeRequest: unknown = request;
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string'
? `${request.baseUrl}${routePath}`
: '<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode
? { code: response.locals.publicErrorCode as string }
: {}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = {
debug: jest.fn(),
info: jest.fn(),
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user',
brokerageAccountId: 'account',
password: 'secret',
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith(
{
requestId: 'request',
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user',
brokerageAccountId: 'account',
},
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
});
it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined);
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.event('debug', 'debug event');
logger.event('warn', 'warn event');
+19 -21
View File
@@ -1,35 +1,32 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
type LogContext = Record<string, string | number | boolean | undefined>;
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([
'service',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'operation',
'grpcCode',
'asOf',
'attempts',
'brokerageAccountId',
'clientRequestId',
'code',
'count',
'durationMs',
'environment',
'found',
'grpcCode',
'method',
'operation',
'requestId',
'route',
'service',
'status',
'userId',
]);
@Injectable()
export class StructuredLogger implements OnApplicationShutdown {
constructor(
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
constructor(private readonly pino: PinoLogger) {}
info(message: string, context?: LogContext): void {
this.write('info', message, context);
@@ -48,8 +45,9 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string,
context: LogContext = {},
): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }),
this.safeContext(context),
message,
);
}
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types';
export class MoneyDto implements Money {
+2
View File
@@ -1,2 +1,4 @@
export const ISO_DATE_LENGTH = 10;
export const ISO_DATE_START = 0;
export const MILLISECONDS_PER_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_000;
+9 -6
View File
@@ -1,3 +1,6 @@
const MIN_PORT = 1;
const MIN_REQUESTS_PER_SECOND = 1;
const DEFAULT_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535;
@@ -7,12 +10,12 @@ export interface Environment {
DATABASE_URL: string;
FRONTEND_ORIGIN: string;
JWT_SECRET: string;
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
PORT: number;
TOKEN_ENCRYPTION_KEY: string;
TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error';
TOKEN_ENCRYPTION_KEY: string;
}
export function validateEnv(config: Record<string, unknown>): Environment {
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
const parsedPort = Number(port);
if (
!Number.isInteger(parsedPort) ||
parsedPort < 1 ||
parsedPort < MIN_PORT ||
parsedPort > MAX_PORT
) {
throw new Error('PORT must be a valid port number');
@@ -71,7 +74,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
if (
typeof tokenEncryptionKey !== 'string' ||
!/^[A-Za-z0-9+/]+={0,2}$/.test(tokenEncryptionKey) ||
!/^[A-Z0-9+/]+={0,2}$/i.test(tokenEncryptionKey) ||
Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) {
throw new Error(
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
}
const parsedTbankRateLimit = Number(tbankRateLimit);
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < 1) {
if (!Number.isInteger(parsedTbankRateLimit) || parsedTbankRateLimit < MIN_REQUESTS_PER_SECOND) {
throw new Error('TBANK_RATE_LIMIT_PER_SECOND must be a positive integer');
}
@@ -92,7 +95,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
throw new Error('TBANK_CA_CERT_PATH must be a file path');
}
if (!['debug', 'info', 'warn', 'error'].includes(logLevel as string)) {
if (!['debug', 'error', 'info', 'warn'].includes(logLevel as string)) {
throw new Error('LOG_LEVEL must be debug, info, warn, or error');
}
+10 -5
View File
@@ -199,8 +199,9 @@ describe('MoexClient', () => {
const url = new URL(input instanceof Request ? input.url : input);
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(url.searchParams.get('start') ?? '');
if (starts.length > 1)
if (starts.length > 1) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
@@ -228,13 +229,14 @@ describe('MoexClient', () => {
const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start));
if (starts.length > 2)
if (starts.length > 2) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
...emptyEvents,
coupons: {
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
},
@@ -246,11 +248,13 @@ describe('MoexClient', () => {
);
});
const result = new MoexClient().bondEvents('BOND');
if (repeatIndex)
if (repeatIndex) {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
else
} else {
// eslint-disable-next-line jest/no-conditional-expect -- The parameterized fixture chooses the scenario; each branch asserts its outcome and the test also asserts common invariants.
await expect(result).resolves.toEqual([
{
type: 'coupon',
@@ -265,6 +269,7 @@ describe('MoexClient', () => {
currency: 'RUB',
},
]);
}
expect(starts).toEqual(['0', '1']);
},
);
+50 -30
View File
@@ -1,5 +1,13 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2';
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0;
const EXPECTED_CURSOR_ROWS = 1;
const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100;
@@ -7,18 +15,18 @@ const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000;
export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string }
| { type: 'maturity'; date: string; value: string; currency: string }
| { type: 'offer'; date: string };
| { currency: string; date: string; type: 'coupon'; value: string } |
{ currency: string; date: string; type: 'maturity'; value: string } |
{ date: string; type: 'offer' };
type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = {
value: string;
pricePercent: string;
date: string;
dateType: string | null;
dateType: null | string;
pricePercent: string;
tradeAt: string;
value: string;
};
@Injectable()
@@ -52,14 +60,15 @@ export class MoexClient {
if (
value === null ||
pricePercent === null ||
Number(pricePercent) <= 0 ||
Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
!date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) ||
!tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
)
) {
return null;
}
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null;
return {
@@ -70,6 +79,7 @@ export class MoexClient {
tradeAt: new Date(tradeIso).toISOString(),
};
}
async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([
@@ -86,8 +96,9 @@ export class MoexClient {
const date = text(row.coupondate);
const value = numberText(row.value);
const currency = currencyText(row.faceunit);
if (date && value && currency)
if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency });
}
}
for (const row of offers) {
const date = text(row.offerdate);
@@ -99,8 +110,9 @@ export class MoexClient {
const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency)
if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency });
}
return events;
}
@@ -110,15 +122,16 @@ export class MoexClient {
paginated = true,
): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = [];
let start = 0;
let start = FIRST_PAGE_OFFSET;
for (;;) {
const query = new URLSearchParams({
'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`,
start: String(start),
'start': String(start),
});
const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows;
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
const current = parseRows(response[block]);
rows.push(...current);
if (!paginated) return rows;
@@ -134,14 +147,15 @@ export class MoexClient {
const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize);
if (
cursors.length !== 1 ||
cursors.length !== EXPECTED_CURSOR_ROWS ||
index !== start ||
total === null ||
pageSize === null ||
pageSize === 0 ||
pageSize === EMPTY_PAGE_SIZE ||
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;
start = index + pageSize;
}
@@ -155,45 +169,51 @@ export class MoexClient {
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json();
if (!body || typeof body !== 'object')
if (!body || typeof body !== 'object') {
throw new Error('MOEX malformed response');
}
return body as IssResponse;
} catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
}
}
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
const columns = block.columns;
if (!columns.every((column) => typeof column === 'string'))
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => {
if (!Array.isArray(values) || values.length !== columns.length)
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
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.
columns.map((column, index) => [column.toLowerCase(), values[index]]),
);
});
}
function text(value: unknown): string | null {
function text(value: unknown): null | string {
return typeof value === 'string' && value.trim() ? value.trim() : null;
}
function numberText(value: unknown): string | null {
function numberText(value: unknown): null | string {
const result = typeof value === 'number' ? String(value) : text(value);
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
return result && /^-?\d+(?:\.\d+)?$/.test(result) ? result : null;
}
function currencyText(value: unknown): string | null {
function currencyText(value: unknown): null | string {
const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null);
}
function integer(value: unknown): number | null {
function integer(value: unknown): null | number {
const result = Number(numberText(value) ?? NaN);
return Number.isSafeInteger(result) && result >= 0 ? result : null;
return Number.isSafeInteger(result) && result >= MIN_CURSOR_VALUE ? result : null;
}
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { MoexClient } from './moex.client';
@Module({
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') =>
@@ -65,6 +66,18 @@ describe('public NCR issue ratings', () => {
);
},
);
it.each(['AA+.ru', 'B-.ru', 'BBB+.ru', 'CCC-.ru', 'CC.ru', 'D.ru'])(
'accepts the public rating grammar: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
expect(await new NcrRatingsClient().rating(isin)).toMatchObject({ value });
},
);
it.each(['AAA+.ru', 'CC-.ru', 'BBBB.ru', 'BBB++.ru'])(
'rejects invalid rating grades: %s', async (value) => {
jest.spyOn(global, 'fetch').mockResolvedValue(response(table(row(value))));
await expect(new NcrRatingsClient().rating(isin)).rejects.toBeInstanceOf(ServiceUnavailableException);
},
);
it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
+29 -14
View File
@@ -2,6 +2,13 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio';
import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2;
const REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6;
@@ -11,11 +18,11 @@ const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = {
agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string;
sourceUrl: string;
observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
};
@Injectable()
@@ -33,9 +40,10 @@ export class NcrRatingsClient {
if (!pending) {
pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating.
if (this.cache.get('registry') === pending)
if (this.cache.get('registry') === pending) {
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);
}
@@ -54,13 +62,16 @@ export class NcrRatingsClient {
.map((_, cell) => $(cell).text().trim())
.get();
if (
table.length !== 1 ||
table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата'
)
) {
throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => {
@@ -69,27 +80,30 @@ export class NcrRatingsClient {
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return;
const raw = cells.eq(2).text().replace(/\s+/g, ' ').trim();
const raw = cells.eq(RATING_COLUMN).text().replace(/\s+/g, ' ').trim();
const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if (
!withdrawn &&
!/^(AAA|AA[+-]?|A[+-]?|BBB[+-]?|BB[+-]?|B[+-]?|CCC[+-]?|CC|C|D)\.ru$/.test(
!/^(?:AAA|A{1,2}[+-]?|B{1,3}[+-]?|CCC[+-]?|C{1,2}|D)\.ru$/.test(
raw,
)
)
) {
throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(),
);
const href = cells.eq(ISIN_COLUMN).find('a').attr('href');
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href))
if (!dateParts || !href || !/^\/ratings\/issues\/\d+\/$/.test(href)) {
throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if (
!Number.isFinite(Date.parse(date)) ||
new Date(date).toISOString().slice(0, 10) !== date
)
new Date(date).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== date
) {
throw new Error('NCR date invalid');
}
const rating: NcrRating = {
agency: 'NCR',
value: withdrawn ? null : raw,
@@ -103,8 +117,9 @@ export class NcrRatingsClient {
previous &&
previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status)
)
) {
throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating);
});
if (!result.size) throw new Error('NCR registry empty');
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client';
@Module({
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1');
});
});
describe('decimal formatting', () => {
it.each([
[120000n, 4, '12'],
[-120030n, 4, '-12.003'],
[1n, 4, '0.0001'],
[0n, 4, '0'],
])('preserves value %s at scale %s while trimming only trailing zeros', (value, scale, expected) => {
expect(formatDecimal({ value, scale })).toBe(expected);
});
it('preserves a long fraction with a trailing zero run', () => {
const significant = '1'.repeat(10_000);
expect(formatDecimal({ value: BigInt(significant + '0'.repeat(10_000)), scale: 20_000 })).toBe('0.' + significant);
});
});
+4 -2
View File
@@ -1,12 +1,14 @@
import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
const NANO = 1_000_000_000n;
export type ProtoDecimal = { units?: string | number; nano?: string | number };
export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function decimal(value: ProtoDecimal): Decimal {
return {
value: BigInt(value.units ?? 0) * NANO + BigInt(value.nano ?? 0),
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
scale: 9,
};
}
@@ -1,24 +1,31 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type {
Prisma,
TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { tbankSourceTtlMs } from './tbank-source.policy';
import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code';
const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1;
const MISSING_DATA_REVISION = 0;
export type MetadataIdentity = {
instrumentUid?: string | null;
figi?: string | null;
figi?: null | string;
instrumentUid?: null | string;
};
export type MetadataLookup = {
type: 'uid' | 'figi';
data: null | TbankInstrument;
id: string;
data: TbankInstrument | null;
type: 'figi' | 'uid';
};
const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates.
@@ -43,8 +50,9 @@ export class InstrumentMetadataRepository {
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []),
]);
if (!identifiers.length)
if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) {
@@ -56,10 +64,10 @@ export class InstrumentMetadataRepository {
),
];
for (
let offset = 0;
let offset = FIRST_BATCH_OFFSET;
offset < values.length;
offset += metadataReadBatchSize
)
) {
lookups.push(
...(await tx.tbankInstrumentLookup.findMany({
where: {
@@ -71,16 +79,17 @@ export class InstrumentMetadataRepository {
},
})),
);
}
}
const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
];
const instruments: MetadataRow[] = [];
for (
let offset = 0;
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += metadataReadBatchSize
)
) {
instruments.push(
...(await tx.tbankInstrument.findMany({
where: {
@@ -89,6 +98,7 @@ export class InstrumentMetadataRepository {
},
})),
);
}
const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
);
@@ -103,7 +113,7 @@ export class InstrumentMetadataRepository {
}
async reserve(
attemptOwner: { userId: string; revision: string },
attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity,
runId: string,
active: ReadonlySet<string>,
@@ -125,8 +135,9 @@ export class InstrumentMetadataRepository {
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
@@ -183,12 +194,13 @@ export class InstrumentMetadataRepository {
['uid', uid],
['figi', figi],
] as const) {
if (id && uid && !aliases.has(`${type}:${id}`))
if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, {
type,
id,
data: { ...result.data, uid },
});
}
}
}
for (const result of aliases.values()) {
@@ -196,8 +208,9 @@ export class InstrumentMetadataRepository {
const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid)
if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) {
const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({
@@ -221,8 +234,8 @@ export class InstrumentMetadataRepository {
...data,
dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload)
? 0
: 1,
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -244,8 +257,8 @@ export class InstrumentMetadataRepository {
fetchedAt: now,
dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? 0
: 1,
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -263,18 +276,19 @@ export class InstrumentMetadataRepository {
},
data: {
state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
if (success)
if (success) {
await this.prisma.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: true },
},
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
}
}
}
@@ -283,7 +297,7 @@ function selectMetadata(
lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>,
) {
const resolve = (type: string, id: string | null | undefined) => {
const resolve = (type: string, id: null | string | undefined) => {
const lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined;
@@ -305,15 +319,12 @@ function selectMetadata(
return {
data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete,
fresh:
complete &&
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fresh: complete,
fetchedAt,
dataRevision: used
.map(
(row) =>
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`,
`${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
)
.join('/'),
};
@@ -1,23 +1,25 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client';
import {
InstrumentMetadataRepository,
metadataKey,
type MetadataIdentity,
type MetadataLookup,
} from './instrument-metadata.repository';
export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository';
type Producer = {
identity: MetadataIdentity;
attempt: TbankSyncAttempt;
identity: MetadataIdentity;
observers: TbankSyncAttempt[];
};
@Injectable()
@@ -49,8 +51,9 @@ export class InstrumentMetadataService {
const connection = await this.access.local(userId);
if (revision && connection.revision !== revision) return null;
const data = (await this.savedMany([identity])).get(metadataKey(identity));
if (!observe && !data?.fresh)
if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity);
}
return {
...data,
work: await this.repository.work(userId, connection.revision, identity),
@@ -119,7 +122,7 @@ export class InstrumentMetadataService {
let useful = false;
let uidFailed = false;
if (identity.instrumentUid) {
let data: TbankInstrument | null | undefined;
let data: null | TbankInstrument | undefined;
try {
data = await this.tbank.getInstrumentByUid(
token,
@@ -129,30 +132,38 @@ export class InstrumentMetadataService {
if (
!identity.figi ||
(error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN')
)
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) {
throw error;
}
uidFailed = true;
}
if (data?.uid && data.uid !== identity.instrumentUid)
if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch');
if (data !== undefined)
}
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi)
if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata');
}
}
if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi(
token,
identity.figi,
);
if (data?.figi && data.figi !== identity.figi)
if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch');
if (data && !data.name?.trim() && !data.ticker?.trim())
}
if (data && !data.name?.trim() && !data.ticker?.trim()) {
throw new Error('Incomplete metadata');
if (data === undefined || (uidFailed && !data))
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation at an untrusted upstream boundary includes missing payloads.
if (data === undefined || (uidFailed && !data)) {
throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup.
@@ -160,8 +171,9 @@ export class InstrumentMetadataService {
uidFailed &&
data?.uid === identity.instrumentUid &&
identity.instrumentUid
)
) {
results.push({ type: 'uid', id: identity.instrumentUid, data });
}
}
if (
!results.length ||
@@ -170,8 +182,9 @@ export class InstrumentMetadataService {
(result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(),
))
)
) {
throw new Error('Incomplete metadata');
}
success = await this.repository.publish(
attempt,
results,
@@ -179,12 +192,13 @@ export class InstrumentMetadataService {
);
}
} 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);
else
} else {
this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
}
}
// Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained.
@@ -196,7 +210,7 @@ export class InstrumentMetadataService {
}
} catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
this.active.clear();
this.producers.clear();
+71 -21
View File
@@ -1,4 +1,3 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import {
BadRequestException,
ConflictException,
@@ -6,16 +5,24 @@ import {
NotFoundException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service';
import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1;
const MEMBERSHIP_REVISION_INCREMENT = 1;
const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string;
id: string;
revision: string;
userId: string;
};
@Injectable()
@@ -48,8 +55,9 @@ export class TbankAccessService {
connection.membershipAt,
new Date(),
)
)
) {
return null;
}
const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
@@ -58,8 +66,9 @@ export class TbankAccessService {
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
)
) {
return { work: previous, launch: false };
}
const data = {
attemptId: randomUUID(),
processRunId,
@@ -95,12 +104,12 @@ export class TbankAccessService {
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
});
return result.count === 1;
return result.count === EXPECTED_UPDATED_CONNECTIONS;
}
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<string | null> {
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
const work = await this.prisma.tbankSyncWork.findFirst({
where: {
...attempt,
@@ -112,6 +121,30 @@ export class TbankAccessService {
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(
attempt: TbankSyncAttempt,
accounts: TbankAccount[],
@@ -119,8 +152,9 @@ export class TbankAccessService {
completeWork = false,
): Promise<boolean> {
const supported = supportedTbankAccounts(accounts);
if (new Set(supported.map(({ id }) => id)).size !== supported.length)
if (new Set(supported.map(({ id }) => id)).size !== supported.length) {
throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below.
@@ -146,6 +180,7 @@ export class TbankAccessService {
const changed =
previous.length !== next.length ||
previous.some((account, index) => {
// eslint-disable-next-line security/detect-object-injection -- The index comes from the paired account array iteration.
const incoming = next[index];
return (
account.accountId !== incoming.accountId ||
@@ -178,14 +213,26 @@ export class TbankAccessService {
where: { userId_revision: { userId, revision } },
data: {
membershipAt: fetchedAt,
membershipRevision: { increment: changed ? 1 : 0 },
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
},
});
if (completeWork)
// 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) {
await tx.tbankSyncWork.update({
where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } },
});
}
return true;
});
}
@@ -194,7 +241,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
if (!claimed.count) return false;
await tx.tbankConnection.update({
@@ -224,17 +271,20 @@ export class TbankAccessService {
accounts: { orderBy: { upstreamOrder: 'asc' } },
},
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (!connection) {
throw new ConflictException({ code: TbankErrorCode.NotConnected });
}
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if (
connection.membershipAt &&
accountId &&
accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId)
)
) {
throw new NotFoundException();
}
return connection;
}
}
@@ -1,14 +1,15 @@
import { 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';
const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: {
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
[TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
},
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: {
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
[TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
},
@@ -18,8 +19,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = {
id: string;
name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
};
export function supportedTbankAccounts(
@@ -39,9 +40,9 @@ export function supportedTbankAccounts(
});
}
function openedOn(seconds: string | number | undefined): string | null {
function openedOn(seconds: number | string | undefined): null | string {
if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null;
return date.toISOString().slice(0, 10);
return date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
}
@@ -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;
}
+26 -24
View File
@@ -6,23 +6,25 @@ import type {
} from './tbank.client';
export type TbankShare = TbankInstrument & {
uid?: string;
figi?: string;
assetUid?: string;
classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean;
assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string;
countryOfRisk?: string;
currency?: string;
shareType?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
figi?: string;
first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
};
export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal;
isComplete?: boolean;
time?: TbankTimestamp;
};
export type TbankLastPrice = {
instrumentUid?: string;
@@ -32,38 +34,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = {
assetUid?: string;
currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number;
priceToBookTtm?: number;
revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number;
totalDebtMrq?: number;
netDebtToEbitda?: number;
};
export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney;
maturityDate?: TbankTimestamp;
amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp;
couponQuantityPerYear?: number;
floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string;
subordinatedFlag?: boolean;
};
export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp;
payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
};
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client';
@Injectable()
@@ -36,7 +38,7 @@ export class TbankMembershipService {
.catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', {
userId,
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
}),
)
.finally(() => this.active.delete(attemptId));
@@ -61,7 +63,7 @@ export class TbankMembershipService {
} catch (error) {
await this.access.failMembership(
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,11 +3,12 @@ import {
tbankSourceTtlMs,
} from './tbank-source.policy';
describe('request-driven source freshness', () => {
describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time',
(source) => {
const fetchedAt = new Date('2026-09-01Z');
// eslint-disable-next-line security/detect-object-injection -- The key comes from the local source-policy test table.
const ttl = tbankSourceTtlMs[source];
expect(
tbankSourceNeedsRefresh(
@@ -1,15 +1,10 @@
// Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = {
membership: 300_000,
portfolio: 60_000,
quote: 60_000,
candleTail: 60_000,
operationsTail: 300_000,
metadata: 86_400_000,
catalog: 86_400_000,
company: 86_400_000,
calendar: 86_400_000,
membership: 3_600_000,
portfolio: 3_600_000,
quote: 3_600_000,
candleTail: 3_600_000,
} as const;
export function tbankSourceNeedsRefresh(
@@ -21,6 +16,7 @@ export function tbankSourceNeedsRefresh(
return (
force ||
!fetchedAt ||
// eslint-disable-next-line security/detect-object-injection -- The key is a member of the closed TbankSource union.
now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
);
}
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto';
@Injectable()
@@ -25,8 +27,9 @@ export class TbankTokenService {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN')
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error;
}
}
+11 -11
View File
@@ -1,9 +1,12 @@
// Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export {
type TbankBond,
type TbankCandle,
@@ -12,8 +15,15 @@ export {
type TbankLastPrice,
type TbankShare,
} from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export {
TbankClient,
type TbankDividend,
@@ -25,13 +35,3 @@ export {
type TbankPositions,
type TbankTimestamp,
} from './tbank.client';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceTtlMs,
tbankSourceNeedsRefresh,
} from './tbank-source.policy';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
+163 -62
View File
@@ -1,5 +1,7 @@
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -9,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' },
cursor: 'cursor',
limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const,
state: TbankOperationState.Executed,
};
it('passes a cursor operations request to OperationsService', async () => {
@@ -57,6 +59,7 @@ describe('TbankClient operations', () => {
const client = Object.create(TbankClient.prototype) as TbankClient;
const call = jest.fn().mockResolvedValue({});
(client as unknown as { call: jest.Mock }).call = call;
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await client[method]('token', 'uid', '2024-02-29', '2024-02-29');
expect(call).toHaveBeenCalledWith('token', undefined, method, {
instrumentId: 'uid',
@@ -111,11 +114,10 @@ describe('TbankClient operations', () => {
ok: true,
});
const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual(
expect.any(ServiceUnavailableException),
);
await jest.advanceTimersByTimeAsync(1000);
await rejected;
await Promise.all([
expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
jest.advanceTimersByTimeAsync(1000),
]);
expect(logger.event).toHaveBeenCalledWith(
'debug',
@@ -140,7 +142,7 @@ describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient;
let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock };
let queue: { run: jest.Mock; size: number };
let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>;
};
@@ -174,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
'recovers metadata after one retry for transient gRPC status %s',
async (code) => {
requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999);
await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
},
);
it('rechecks a full queue before admitting a metadata retry', async () => {
it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50;
requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi');
await jest.advanceTimersByTimeAsync(1999);
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(queue.run).not.toHaveBeenCalled();
queue.size = 49;
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
expect(jest.getTimerCount()).toBe(0);
});
it('does not bypass queue admission after an upstream failure', async () => {
@@ -221,46 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50;
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0;
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(await result).toBeInstanceOf(ServiceUnavailableException);
expect(jest.getTimerCount()).toBe(0);
});
it('stops retrying an unavailable upstream after three RPCs', async () => {
it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect(
client.getInstrumentByUid('token', 'uid'),
).rejects.toBeInstanceOf(ServiceUnavailableException);
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(1000),
]);
await jest.advanceTimersByTimeAsync(6000);
await result;
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
});
@@ -282,6 +244,7 @@ describe('TbankClient metadata retries', () => {
async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
// eslint-disable-next-line security/detect-object-injection -- RPC method names come from the literal test table.
await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0);
@@ -319,3 +282,141 @@ describe('TbankClient metadata retries', () => {
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',
);
});
});
+130 -94
View File
@@ -1,4 +1,3 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import {
ChannelCredentials,
Client,
@@ -17,8 +16,6 @@ import {
import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type {
TbankBond,
@@ -29,11 +26,29 @@ import type {
TbankShare,
} from './tbank-market.types';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../common/time';
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 MIN_RATE_DELAY_MS = 1;
const FIRST_ATTEMPT = 1;
const ATTEMPT_INCREMENT = 1;
const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000;
@@ -42,78 +57,78 @@ type Unary<Request, Response> = (
request: Request,
metadata: Metadata,
options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void,
callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>;
export type TbankDecimal = { units?: string | number; nano?: string | number };
export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = {
id: string;
type: string;
name: string;
openedDate?: { seconds?: string | number };
openedDate?: { seconds?: number | string };
type: string;
};
export type TbankPortfolio = {
positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[];
};
export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = {
uid?: string;
brand?: { logoName?: null | string };
figi?: string;
instrumentType?: string;
ticker?: string;
name?: string;
isin?: string;
brand?: { logoName?: string | null };
name?: string;
ticker?: string;
uid?: string;
};
export type TbankDividend = {
dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string;
lastBuyDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
recordDate?: TbankTimestamp;
};
export type TbankTimestamp = { seconds?: string | number; nanos?: number };
export type TbankTimestamp = { nanos?: number; seconds?: number | string };
export type TbankOperationItem = {
state?: string;
accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp;
type?: string;
description?: string;
figi?: string;
instrumentUid?: string;
id?: string;
instrumentType?: string;
ticker?: string;
instrumentUid?: string;
name?: string;
payment?: TbankMoney;
price?: TbankMoney;
commission?: TbankMoney;
accruedInt?: TbankMoney;
quantity?: string | number;
quantityDone?: string | number;
quantity?: number | string;
quantityDone?: number | string;
state?: string;
ticker?: string;
type?: string;
};
export type TbankOperationsByCursorRequest = {
accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number;
state: 'OPERATION_STATE_EXECUTED';
state: TbankOperationState.Executed;
to?: TbankTimestamp;
};
export type TbankOperationsByCursorResponse = {
hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[];
nextCursor?: string;
};
type TbankNamespace = {
tinkoff: {
@@ -123,7 +138,7 @@ type TbankNamespace = {
class RateLimiter {
private readonly waiting: (() => void)[] = [];
private starts = 0;
private starts = INITIAL_REQUEST_COUNT;
private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {}
@@ -148,16 +163,16 @@ class RateLimiter {
const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now;
this.starts = 0;
this.starts = INITIAL_REQUEST_COUNT;
}
while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1;
this.starts++;
this.waiting.shift()?.();
}
if (this.waiting.length && this.starts === this.perSecond) {
setTimeout(
() => this.drain(),
Math.max(1, RATE_WINDOW_MS - (now - this.windowStartedAt)),
Math.max(MIN_RATE_DELAY_MS, RATE_WINDOW_MS - (now - this.windowStartedAt)),
);
}
}
@@ -198,11 +213,13 @@ export class TbankClient {
).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
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.
? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) =>
new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new (
address: string,
credentials: ChannelCredentials,
@@ -246,7 +263,7 @@ export class TbankClient {
token,
this.users,
'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' },
{ status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []);
}
@@ -268,15 +285,15 @@ export class TbankClient {
getInstrumentByFigi(
token: string,
figi: string,
): Promise<TbankInstrument | null> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
): Promise<null | TbankInstrument> {
return this.getInstrument(token, InstrumentIdType.Figi, figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<TbankInstrument | null> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
): Promise<null | TbankInstrument> {
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
}
getDividends(
@@ -304,11 +321,40 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
instrumentKind: TbankInstrumentType.Share,
},
).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[]> {
return this.call<{ instruments?: TbankBond[] }>(
token,
@@ -316,21 +362,23 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_BOND',
instrumentKind: TbankInstrumentType.Bond,
},
).then((response) => response.instruments ?? []);
}
getBond(token: string, uid: string): Promise<TbankBond | null> {
getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>(
token,
this.sparseInstruments,
'bondBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
}
getBondCoupons(
token: string,
uid: string,
@@ -349,19 +397,19 @@ export class TbankClient {
).then((response) => response.events ?? []);
}
getShare(token: string, uid: string): Promise<TbankShare | null> {
getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>(
token,
this.instruments,
'shareBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
}
getAssetDescription(token: string, assetUid: string): Promise<string | null> {
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>(
token,
this.instruments,
@@ -375,7 +423,7 @@ export class TbankClient {
getFundamentals(
token: string,
assetUid: string,
): Promise<TbankFundamentals | null> {
): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>(
token,
this.sparseInstruments,
@@ -399,13 +447,7 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>(
token,
this.marketData,
@@ -434,7 +476,7 @@ export class TbankClient {
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY',
interval: TbankCandleInterval.Day,
limit: 2400,
},
).then((response) => response.candles ?? []);
@@ -442,9 +484,9 @@ export class TbankClient {
private async getInstrument(
token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI',
idType: InstrumentIdType,
id: string,
): Promise<TbankInstrument | null> {
): Promise<null | TbankInstrument> {
try {
const response = await this.call<{ instrument?: TbankInstrument }>(
token,
@@ -466,13 +508,13 @@ export class TbankClient {
request: object,
): Promise<Response> {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull)
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
if (queueFull) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
);
@@ -485,52 +527,45 @@ export class TbankClient {
} catch (error) {
const grpcCode = (error as ServiceError).code;
const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' }
? { code: TbankErrorCode.Unavailable }
: { grpcCode };
const retryable = queueFull
? metadataCall
: [
status.UNAVAILABLE,
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
const retryable = !queueFull && [
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
this.logger.event('warn', 'tbank.rpc.retry', {
operation: method,
...failureContext,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts,
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.UNAUTHENTICATED, status.PERMISSION_DENIED].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.
throw new Error('INVALID_TBANK_TOKEN');
throw new Error(TbankErrorCode.InvalidToken);
}
if (
grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall)
)
) {
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>(
@@ -542,6 +577,7 @@ export class TbankClient {
return new Promise((resolve, reject) => {
const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`);
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
client[method](
request as never,
metadata,
@@ -555,6 +591,6 @@ export class TbankClient {
// Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
function utcSeconds(date: string, nextDay = false): number {
const instant = new Date(`${date}T00:00:00Z`);
if (nextDay) instant.setUTCDate(instant.getUTCDate() + 1);
if (nextDay) instant.setUTCDate(instant.getUTCDate() + NEXT_DAY_OFFSET);
return Math.floor(instant.valueOf() / MILLISECONDS_PER_SECOND);
}
+7 -4
View File
@@ -1,5 +1,7 @@
import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const VERSION_SIZE_BYTES = 1;
const KEY_SIZE_BYTES = 32;
const VERSION = 1;
@@ -11,8 +13,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64');
if (decoded.length !== KEY_SIZE_BYTES)
if (decoded.length !== KEY_SIZE_BYTES) {
throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded);
}
@@ -33,15 +36,15 @@ export class TbankCrypto {
decrypt(payload: string): string {
const bytes = Buffer.from(payload, 'base64');
if (bytes[0] !== VERSION || bytes.length < 1 + NONCE_SIZE + TAG_SIZE) {
if (bytes[0] !== VERSION || bytes.length < VERSION_SIZE_BYTES + NONCE_SIZE + TAG_SIZE) {
throw new Error('Invalid encrypted token');
}
const nonce = bytes.subarray(1, 1 + NONCE_SIZE);
const nonce = bytes.subarray(VERSION_SIZE_BYTES, VERSION_SIZE_BYTES + NONCE_SIZE);
const tag = bytes.subarray(-TAG_SIZE);
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
decipher.setAuthTag(tag);
return Buffer.concat([
decipher.update(bytes.subarray(1 + NONCE_SIZE, -TAG_SIZE)),
decipher.update(bytes.subarray(VERSION_SIZE_BYTES + NONCE_SIZE, -TAG_SIZE)),
decipher.final(),
]).toString('utf8');
}
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common';
import { TbankMembershipService } from './tbank-membership.service';
import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client';
+5 -6
View File
@@ -3,18 +3,18 @@ import {
INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger';
import { API_PREFIX, createOpenApiDocument } from './openapi';
import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config';
import { API_PREFIX, createOpenApiDocument } from './openapi';
export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService);
@@ -28,10 +28,9 @@ export function configureApp(app: INestApplication): void {
});
app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes(
new ValidationPipe({
forbidUnknownValues: true,
transform: true,
whitelist: true,
validationError: { target: false, value: false },
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
@@ -114,8 +114,8 @@ describe('AuthService', () => {
where,
data,
}: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string };
where: { id?: string; refreshTokenHash?: string };
}) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash;
+37 -23
View File
@@ -6,9 +6,15 @@ import {
import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
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 { AuthUser, RefreshPayload } from './auth.types';
import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000;
@@ -16,6 +22,7 @@ const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH =
// eslint-disable-next-line sonarjs/no-hardcoded-passwords -- Public dummy bcrypt hash preserves login timing for nonexistent users; it is not a credential.
'$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable()
@@ -28,12 +35,13 @@ export class AuthService {
async register(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
): Promise<AuthenticationResult> {
const existing = await this.prisma.user.findUnique({
where: { email },
});
if (existing)
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
let user: User;
try {
@@ -45,8 +53,9 @@ export class AuthService {
},
});
} catch (error) {
if (isUniqueConstraintError(error))
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error;
}
return this.createSession(user);
@@ -55,7 +64,7 @@ export class AuthService {
async login(
email: string,
password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({
where: { email },
});
@@ -63,24 +72,26 @@ export class AuthService {
password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !valid)
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
if (!user || !valid) {
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user);
}
async refresh(
token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
): Promise<AuthenticationResult> {
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
include: { user: true },
@@ -91,12 +102,14 @@ export class AuthService {
session.revokedAt ||
session.expiresAt <= new Date()
) {
if (session && !session.revokedAt)
if (session && !session.revokedAt) {
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)) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token))
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.rotateSession(session, session.refreshTokenHash);
}
@@ -132,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> {
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;
}
private async createSession(
user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({
data: {
id: randomUUID(),
@@ -153,7 +166,7 @@ export class AuthService {
private async rotateSession(
session: Session & { user: User },
expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> {
): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync(
{
sub: session.userId,
@@ -172,8 +185,9 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== 1)
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
}
@@ -197,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'P2002'
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
+11 -5
View File
@@ -1,17 +1,23 @@
export interface AuthUser {
id: string;
email: string;
id: string;
}
export type AuthenticationResult = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export interface AccessPayload {
sub: string;
sid: string;
sub: string;
type: 'access';
}
export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string;
sid: string;
sub: string;
type: 'refresh';
}
+3 -2
View File
@@ -2,11 +2,12 @@ import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@Module({
imports: [
@@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types';
@@ -17,8 +19,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
}
validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
if (payload.type !== 'access') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return payload;
}
}
@@ -1,3 +1,5 @@
import type { Request, Response } from 'express';
import {
Body,
Controller,
@@ -17,11 +19,12 @@ import {
ApiProperty,
ApiTags,
} from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { CredentialsDto } from './auth.dto';
import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types';
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token';
@@ -82,14 +85,9 @@ export class AuthController {
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return this.respond(response, await this.auth.refresh(token));
}
@Post('logout')
@@ -114,11 +112,7 @@ export class AuthController {
private respond(
response: Response,
result: {
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
result: AuthenticationResult,
) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken };
@@ -129,6 +123,7 @@ export class AuthController {
}
private refreshToken(request: Request): string | undefined {
// eslint-disable-next-line security/detect-object-injection -- The cookie name is a fixed application constant; its value is narrowed below.
const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined;
}
+9 -4
View File
@@ -7,6 +7,9 @@ import {
ValidatorConstraint,
} from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32;
const ASCII_DELETE = 127;
@@ -19,18 +22,19 @@ class EmailConstraint {
return (
typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 &&
value.split('@').length === EMAIL_PART_COUNT &&
!value
.split('')
.some(
(character) =>
/\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE,
character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) &&
value.split('@').every(Boolean)
value.split('@').every(Boolean)
);
}
defaultMessage(): string {
return 'must be a valid email address';
}
@@ -44,6 +48,7 @@ class PasswordBytesConstraint {
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
);
}
defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes';
}
@@ -1,11 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { PinoLogger } from 'nestjs-pino';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
constructor(private readonly logger: PinoLogger) {
super();
}
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
const request = context
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub)
this.requestContext.set({ userId: request.user.sub });
if (request.user?.sub) {
this.logger.assign({ userId: request.user.sub });
}
return Boolean(allowed);
}
}
@@ -1,40 +1,44 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import {
BadRequestException,
ConflictException,
NotFoundException,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
import {
bankPaymentSystems,
BankPeriodQueryDto,
CreateBankCardDto,
CreateBankOperationDto,
} from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT',
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND',
InvalidMoney: 'BANK_INVALID_MONEY',
InvalidDate: 'BANK_INVALID_DATE',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
InvalidRefund: 'BANK_INVALID_REFUND',
RefundLimit: 'BANK_REFUND_LIMIT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
IntentConflict: 'BANK_INTENT_CONFLICT',
OperationDeleted: 'BANK_OPERATION_DELETED',
CardArchived: 'BANK_CARD_ARCHIVED',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
WriteConflict: 'BANK_WRITE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
} as const;
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
const NEXT_MONTH_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0;
export enum BankErrorCode {
CardArchived = 'BANK_CARD_ARCHIVED',
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
IntentConflict = 'BANK_INTENT_CONFLICT',
InvalidDate = 'BANK_INVALID_DATE',
InvalidInput = 'BANK_INVALID_INPUT',
InvalidMoney = 'BANK_INVALID_MONEY',
InvalidRefund = 'BANK_INVALID_REFUND',
NotFound = 'BANK_NOT_FOUND',
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
OperationDeleted = 'BANK_OPERATION_DELETED',
RefundLimit = 'BANK_REFUND_LIMIT',
VersionConflict = 'BANK_VERSION_CONFLICT',
WriteConflict = 'BANK_WRITE_CONFLICT',
}
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code });
@@ -44,8 +48,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0);
return { from, to: end.toISOString().slice(0, 10) };
end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
}
if (
query.month ||
@@ -54,8 +58,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
!isCalendarDate(query.from) ||
!isCalendarDate(query.to) ||
query.from > query.to
)
) {
return fail();
}
return { from: query.from, to: query.to };
}
export function conflict(
@@ -93,8 +98,9 @@ export function currentDate(value: string) {
if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
}
export function name(value: string, max: number) {
if (typeof value !== 'string' || !value.trim() || value.trim().length > max)
if (typeof value !== 'string' || !value.trim() || value.trim().length > max) {
fail();
}
return value.trim();
}
export function cardInput(dto: CreateBankCardDto) {
@@ -104,8 +110,9 @@ export function cardInput(dto: CreateBankCardDto) {
if (
dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem)
)
) {
fail();
}
return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, MAX_CARD_NAME_LENGTH),
@@ -120,8 +127,9 @@ export function operationInput(dto: CreateBankOperationDto) {
dto.description != null &&
(typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
)
) {
fail();
}
return {
kind: dto.kind,
cardId: dto.cardId,
@@ -1,24 +1,27 @@
import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import {
BankCardDto,
CreateBankCardDto,
UpdateBankCardDto,
} from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import {
cardInput,
BankErrorCode,
cardInput,
conflict,
currentDate,
fail,
fingerprint,
missing,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankCardsService {
@@ -26,6 +29,7 @@ export class BankCardsService {
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({
where: {
@@ -47,6 +51,7 @@ export class BankCardsService {
archivedAt: card.archivedAt?.toISOString() ?? null,
};
}
list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) =>
Promise.all(
@@ -59,11 +64,13 @@ export class BankCardsService {
),
);
}
get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)),
);
}
createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto),
hash = fingerprint(input);
@@ -71,8 +78,10 @@ export class BankCardsService {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
if (existing.createFingerprint !== hash) {
conflict(BankErrorCode.IntentConflict);
}
return this.cardView(tx, existing);
}
currentDate(input.openingDate);
@@ -85,6 +94,7 @@ export class BankCardsService {
);
});
}
updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id);
@@ -93,13 +103,9 @@ export class BankCardsService {
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
dto.bankName === undefined ? previous.bankName : dto.bankName,
lastFour:
dto.lastFour === undefined
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
paymentSystem:
dto.paymentSystem === undefined
? previous.paymentSystem
@@ -111,8 +117,9 @@ export class BankCardsService {
const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt)
if (financial && previous.archivedAt) {
conflict(BankErrorCode.CardArchived);
}
currentDate(input.openingDate);
if (
await tx.bankCardOperation.findFirst({
@@ -123,8 +130,9 @@ export class BankCardsService {
OR: [{ cardId: id }, { targetCardId: id }],
},
})
)
) {
fail(BankErrorCode.OpeningDateConflict);
}
const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version },
data: {
@@ -140,7 +148,7 @@ export class BankCardsService {
}),
},
});
if (changed.count !== 1) conflict();
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
});
}
@@ -1,20 +1,22 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { GoneException, Injectable } from '@nestjs/common';
import {
BankCardOperation,
Prisma,
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import {
BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto,
BankPageQueryDto,
CreateBankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
UpdateBankOperationDto,
} from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import {
BankErrorCode,
conflict,
@@ -25,7 +27,9 @@ import {
operationInput,
periodBounds,
} from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient;
@Injectable()
export class BankOperationsService {
@@ -33,6 +37,7 @@ export class BankOperationsService {
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView(
tx: Tx,
row: BankCardOperation,
@@ -76,7 +81,7 @@ export class BankOperationsService {
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n),
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
)
: null,
date: row.date,
@@ -85,6 +90,7 @@ export class BankOperationsService {
createdAt: row.createdAt.toISOString(),
};
}
private async validateOperation(
tx: Tx,
userId: string,
@@ -102,23 +108,26 @@ export class BankOperationsService {
if (
(id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate
)
) {
fail(BankErrorCode.InvalidDate);
}
}
if (
old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
)
) {
fail(BankErrorCode.ImmutableOperation);
}
if (input.kind === BankOperationKind.Transfer) {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
)
) {
fail();
}
} else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
@@ -133,11 +142,13 @@ export class BankOperationsService {
!(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
)
) {
missing();
}
} else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId)
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail();
}
if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation(
@@ -149,8 +160,9 @@ export class BankOperationsService {
source.deletedAt ||
source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId
)
) {
fail(BankErrorCode.InvalidRefund);
}
if (
(
await tx.bankCardOperation.updateMany({
@@ -162,9 +174,10 @@ export class BankOperationsService {
},
data: { version: { increment: 1 } },
})
).count !== 1
)
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
@@ -175,11 +188,12 @@ export class BankOperationsService {
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) +
input.amountKopecks >
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
input.amountKopecks >
source.amountKopecks
)
) {
conflict(BankErrorCode.RefundLimit);
}
}
if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({
@@ -189,12 +203,14 @@ export class BankOperationsService {
refunds.length &&
(input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) >
input.amountKopecks)
)
refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
input.amountKopecks)
) {
conflict(BankErrorCode.DependentRefunds);
}
}
}
createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto),
hash = fingerprint(normalized);
@@ -204,10 +220,13 @@ export class BankOperationsService {
});
if (existing) {
if (existing.userId !== userId) missing();
if (existing.createFingerprint !== hash)
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
if (existing.createFingerprint !== hash) {
conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt)
}
if (existing.deletedAt) {
throw new GoneException({ code: BankErrorCode.OperationDeleted });
}
return this.operationView(tx, existing);
}
const input = { ...normalized };
@@ -220,6 +239,7 @@ export class BankOperationsService {
);
});
}
getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
@@ -227,6 +247,7 @@ export class BankOperationsService {
return this.operationView(tx, row);
});
}
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => {
const old = await this.repository.operation(tx, userId, id);
@@ -251,30 +272,34 @@ export class BankOperationsService {
where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== 1
)
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView(
tx,
await this.repository.operation(tx, userId, id),
);
});
}
deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return;
if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId)
if (row.targetCardId) {
await this.repository.card(tx, userId, row.targetCardId, true);
}
if (
row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
)
) {
conflict(BankErrorCode.DependentRefunds);
}
if (row.originalExpenseId) {
const source = await this.repository.operation(
tx,
@@ -287,9 +312,10 @@ export class BankOperationsService {
where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== 1
)
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
}
if (
(
@@ -297,11 +323,13 @@ export class BankOperationsService {
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== 1
)
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
});
}
history(
userId: string,
id: string,
@@ -313,8 +341,8 @@ export class BankOperationsService {
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id);
const page = query.page ?? 1,
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE;
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
@@ -327,7 +355,7 @@ export class BankOperationsService {
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit,
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return {
@@ -1,16 +1,21 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { conflict, missing, name } from './bank-card-input';
import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable()
export class ExpenseCategoriesService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
@@ -24,6 +29,7 @@ export class ExpenseCategoriesService {
version,
}));
}
createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => {
@@ -44,6 +50,7 @@ export class ExpenseCategoriesService {
};
});
}
updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => {
@@ -59,14 +66,15 @@ export class ExpenseCategoriesService {
version: { increment: 1 },
},
})
).count !== 1
)
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + 1,
version: version + VERSION_INCREMENT,
};
});
}
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { periodBounds } from './bank-card-input';
@Injectable()
export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module';
import { BankCardsController } from './presentation/bank-cards.controller';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsController } from './presentation/bank-cards.controller';
@Module({
imports: [AuthModule],
controllers: [BankCardsController],
@@ -1,3 +1,10 @@
export const FIRST_PAGE = 1;
export const MIN_ENTITY_VERSION = 1;
export const MIN_CATEGORY_NAME_LENGTH = 1;
export const MIN_CARD_NAME_LENGTH = 1;
export const MAX_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -1,4 +1,6 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money';
export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', {
@@ -10,11 +12,11 @@ export function moscowToday(now = new Date()): string {
}
export function isCalendarDate(value: string): boolean {
if (value.length !== 10 || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.length !== ISO_DATE_LENGTH || !/^\d{4}-\d{2}-\d{2}$/.test(value)) return false;
if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`);
return (
!Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
!Number.isNaN(date.getTime()) && date.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === value
);
}
@@ -22,10 +24,10 @@ export function bankBalance(
cardId: string,
opening: bigint,
operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint;
cardId: string;
kind: string;
targetCardId: null | string;
}>,
): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories.
@@ -35,19 +37,21 @@ export function bankBalance(
if (
operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer
)
) {
balance -= operation.amountKopecks;
else if (
} else if (
operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund
)
) {
balance += operation.amountKopecks;
}
}
if (
operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId
)
) {
balance += operation.amountKopecks;
}
}
return balance;
}
@@ -55,17 +59,17 @@ export function bankBalance(
export function bankSummary(
period: { from: string; to: string },
operations: Array<{
kind: string;
date: string;
amountKopecks: bigint;
categoryId: string | null;
categoryId: null | string;
date: string;
kind: string;
}>,
) {
let expenses = 0n;
let refunds = 0n;
let expenses = ZERO_KOPECKS;
let refunds = ZERO_KOPECKS;
const categories = new Map<
string,
{ categoryId: string; expenses: bigint; refunds: bigint; net: bigint }
{ categoryId: string; expenses: bigint; net: bigint; refunds: bigint }
>();
// ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) {
@@ -73,24 +77,25 @@ export function bankSummary(
if (
operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund
)
) {
continue;
}
const expense =
operation.kind === BankOperationKind.Expense
? operation.amountKopecks
: 0n;
: ZERO_KOPECKS;
const refund =
operation.kind === BankOperationKind.Refund
? operation.amountKopecks
: 0n;
: ZERO_KOPECKS;
expenses += expense;
refunds += refund;
if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId,
expenses: 0n,
refunds: 0n,
net: 0n,
expenses: ZERO_KOPECKS,
refunds: ZERO_KOPECKS,
net: ZERO_KOPECKS,
};
category.expenses += expense;
category.refunds += refund;
@@ -39,8 +39,9 @@ describe('RUB money', () => {
});
it('requires positive operation amounts, including at the maximum', () => {
for (const value of ['0', '-0.00', '-0.01'])
for (const value of ['0', '-0.00', '-0.01']) {
expect(() => parseRubMoney({ currency: 'RUB', value }, true)).toThrow();
}
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true),
@@ -1,6 +1,11 @@
import { decimalFromString, formatDecimal } from '../../../../common/decimal';
import type { Money } from '../../../../common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
export const ZERO_KOPECKS = 0n;
const RUB_DECIMAL_PLACES = 2;
const MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n;
@@ -9,22 +14,26 @@ export function parseRubMoney(money: Money, positive = false): bigint {
money?.currency !== 'RUB' ||
typeof money.value !== 'string' ||
money.value !== money.value.trim()
)
) {
throw new TypeError('Expected RUB money with a decimal string');
}
const amount = decimalFromString(money.value);
if (amount.scale > 2)
if (amount.scale > RUB_DECIMAL_PLACES) {
throw new RangeError('RUB money supports at most two decimal places');
const kopecks = amount.value * 10n ** BigInt(2 - amount.scale);
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS)
}
const kopecks = amount.value * DECIMAL_RADIX ** BigInt(RUB_DECIMAL_PLACES - amount.scale);
if (kopecks < MIN_KOPECKS || kopecks > MAX_KOPECKS) {
throw new RangeError('RUB money is outside the signed 64-bit range');
if (positive && kopecks <= 0n)
}
if (positive && kopecks <= ZERO_KOPECKS) {
throw new RangeError('Operation amount must be positive');
}
return kopecks;
}
export function rubMoney(kopecks: bigint): Money {
return {
currency: 'RUB',
value: formatDecimal({ value: kopecks, scale: 2 }),
value: formatDecimal({ value: kopecks, scale: RUB_DECIMAL_PLACES }),
};
}
@@ -1,14 +1,19 @@
import { BankOperationKind } from '../domain/bank-operation-kind';
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankErrorCode,
conflict,
missing,
} from '../application/bank-card-input';
import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1;
type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000;
@@ -28,7 +33,7 @@ const seeds = [
export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) {
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try {
return await this.prisma.$transaction(run, {
maxWait: TRANSACTION_MAX_WAIT_MS,
@@ -40,37 +45,41 @@ export class BankCardsRepository {
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error &&
/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;
}
if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
throw error;
}
}
}
async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing();
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card;
}
async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing();
return row;
}
async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) {
const seedKey = index === seeds.length - 1 ? 'other' : String(index);
const seedKey = index === seeds.length - LAST_SEED_OFFSET ? 'other' : String(index);
if (
!(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } },
}))
)
) {
await tx.expenseCategory.create({
data: {
id: randomUUID(),
@@ -80,8 +89,10 @@ export class BankCardsRepository {
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
}
}
}
summaryData(
userId: string,
period: { from: string; to: string },
@@ -1,9 +1,3 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import {
ApiProperty,
ApiPropertyOptional,
@@ -28,7 +22,20 @@ import {
ValidateIf,
ValidateNested,
} from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto';
import {
FIRST_PAGE,
MAX_CARD_NAME_LENGTH,
MAX_CATEGORY_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
MIN_CARD_NAME_LENGTH,
MIN_CATEGORY_NAME_LENGTH,
MIN_ENTITY_VERSION,
} from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
const MIN_PAGE_SIZE = 1;
export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100;
@@ -41,17 +48,19 @@ export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString()
// eslint-disable-next-line security/detect-unsafe-regex -- Anchored decimal grammar: the literal decimal point separates disjoint digit runs; backtracking is linear.
@Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string;
}
export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ minLength: 1, maxLength: MAX_CARD_NAME_LENGTH })
@ApiProperty({ minLength: MIN_CARD_NAME_LENGTH, maxLength: MAX_CARD_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MinLength(MIN_CARD_NAME_LENGTH)
@MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string;
@ApiPropertyOptional({
type: String,
nullable: true,
@@ -61,20 +70,24 @@ export class CreateBankCardDto {
@Transform(trim)
@IsString()
@MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null;
bankName?: null | string;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional()
@Matches(/^[0-9]{4}$/)
lastFour?: string | null;
@Matches(/^\d{4}$/)
lastFour?: null | string;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional()
@IsIn(bankPaymentSystems)
paymentSystem?: string | null;
paymentSystem?: null | string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
@@ -84,50 +97,56 @@ export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
@ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean()
archived?: boolean;
}
export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH })
@ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim)
@IsString()
@MinLength(1)
@MinLength(MIN_CATEGORY_NAME_LENGTH)
@MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string;
}
export class UpdateExpenseCategoryDto extends CategoryNameDto {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
}
export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds)
declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID()
originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto })
@IsDefined()
@ValidateNested()
@Type(() => RubMoneyInputDto)
declare amount: MoneyDto;
@ApiProperty({ format: 'date' })
@IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string;
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined)
@IsString()
@@ -138,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false },
) {
@ApiProperty({ minimum: 1 }) @IsInt() @Min(1) declare version: number;
@ApiProperty({ minimum: MIN_ENTITY_VERSION }) @IsInt() @Min(MIN_ENTITY_VERSION) declare version: number;
}
export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] })
@@ -147,21 +166,22 @@ export class BankCardsQueryDto {
includeArchived?: string;
}
export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 })
@ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Min(FIRST_PAGE)
page?: number;
@ApiPropertyOptional({
minimum: 1,
minimum: MIN_PAGE_SIZE,
maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Min(MIN_PAGE_SIZE)
@Max(MAX_BANK_PAGE_SIZE)
limit?: number;
}
@@ -173,6 +193,7 @@ export class BankPeriodQueryDto {
@IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive start date; requires to.',
@@ -180,6 +201,7 @@ export class BankPeriodQueryDto {
@IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string;
@ApiPropertyOptional({
format: 'date',
description: 'Inclusive end date; requires from.',
@@ -199,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
cardId?: string;
}
export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 })
@ApiProperty({ minimum: MIN_ENTITY_VERSION })
@Type(() => Number)
@IsInt()
@Min(1)
@Min(MIN_ENTITY_VERSION)
declare version: number;
}
export class ExpenseCategoryDto {
@@ -215,16 +237,20 @@ export class BankCardDto {
@ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null;
null | string;
@ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null;
declare paymentSystem: null | string;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null;
null | string;
@ApiProperty({ type: Number }) declare version: number;
}
export class BankOperationDto {
@@ -233,27 +259,35 @@ export class BankOperationDto {
@ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null;
null | string;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null;
null | string;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description:
string | null;
null | string;
@ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string;
}
export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean;
@@ -269,7 +303,7 @@ export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: string;
@ApiProperty({ type: String, nullable: true }) declare cardId: string | null;
@ApiProperty({ type: String, nullable: true }) declare cardId: null | string;
@ApiProperty({ type: MoneyDto }) declare expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@@ -1,3 +1,5 @@
import type { Request } from 'express';
import {
Body,
Controller,
@@ -21,12 +23,16 @@ import {
ApiTags,
getSchemaPath,
} from '@nestjs/swagger';
import type { Request } from 'express';
import {
ApiProblemResponses,
ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
import {
BankCardDto,
BankCardsQueryDto,
@@ -45,10 +51,6 @@ import {
UpdateBankOperationDto,
UpdateExpenseCategoryDto,
} from './bank-card.dto';
import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service';
import { GetBankSummaryUseCase } from '../application/get-bank-summary.use-case';
type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({
status: 409,
@@ -82,26 +84,31 @@ export class BankCardsController {
private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase,
) {}
@Get()
@ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true');
}
@Post()
@ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body);
}
@Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub);
}
@Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name);
}
@Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory(
@@ -116,11 +123,13 @@ export class BankCardsController {
body.version,
);
}
@Get('summary')
@ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query);
}
@Post('operations')
@ApiCreatedResponse({ type: BankOperationDto })
createOperation(
@@ -129,11 +138,13 @@ export class BankCardsController {
) {
return this.operations.createOperation(req.user.sub, body);
}
@Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id);
}
@Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto })
updateOperation(
@@ -143,6 +154,7 @@ export class BankCardsController {
) {
return this.operations.updateOperation(req.user.sub, id, body);
}
@Delete('operations/:id')
@HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse()
@@ -153,6 +165,7 @@ export class BankCardsController {
) {
return this.operations.deleteOperation(req.user.sub, id, query.version);
}
@Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto })
history(
@@ -162,6 +175,7 @@ export class BankCardsController {
) {
return this.operations.history(req.user.sub, id, query);
}
@Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto })
expenses(
@@ -171,11 +185,13 @@ export class BankCardsController {
) {
return this.operations.history(req.user.sub, id, query, true);
}
@Get(':id')
@ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id);
}
@Patch(':id')
@ApiOkResponse({ type: BankCardDto })
update(
@@ -1,6 +1,8 @@
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true })
@@ -40,15 +42,26 @@ export class BrokerageAccountDto {
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' })
declare openedOn: string | null;
declare openedOn: null | string;
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Preserve the published required nullable schema; partial internal models may omit the field.
@ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null;
portfolioSummary?: null | PortfolioSummaryDto;
}
export class BrokerageAccountsResourceDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Nullable array union requires isArray in the explicit wire schema.
@ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
declare data: BrokerageAccountDto[] | null;
@ApiProperty({ type: 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,7 +1,8 @@
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { Controller, Get, Req, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
@@ -17,10 +18,7 @@ export class BrokerageAccountsController {
@Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
list(
@Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
list(@Req() request: Request & { user: AccessPayload }) {
return this.accounts.list(request.user.sub);
}
}
@@ -1,12 +1,14 @@
import { PortfolioModule } from '../portfolio/portfolio.module';
import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({
imports: [AuthModule, TbankModule, PortfolioModule],
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService],
@@ -1,8 +1,8 @@
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { BrokerageAccountsService } from './brokerage-accounts.service';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageAccountsService } from './brokerage-accounts.service';
jest.mock('../portfolio/application/portfolio.service', () => ({
PortfolioService: class PortfolioService {},
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => {
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 () => {
portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' },
membership: { ...meta, completeness: 'incomplete' },
accounts: [],
});
expect((await service.list('owner', true)).data).toBeNull();
expect((await service.list('owner')).data).toBeNull();
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 () => {
@@ -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({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1,
bondPositionCount: 1,
});
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');
});
});
@@ -1,17 +1,22 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PortfolioService } from '../portfolio/portfolio.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageAccountDto } from './brokerage-account.dto';
import { PortfolioService } from '../portfolio/portfolio.api';
import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
@Injectable()
export class BrokerageAccountsService {
constructor(private readonly portfolio: PortfolioService) {}
constructor(
private readonly portfolio: PortfolioService,
private readonly sync: TbankSyncRepository,
) {}
async list(userId: string, observe = false) {
const saved = await this.portfolio.saved(userId, 'all', observe);
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
const saved = await this.portfolio.saved(userId, 'all');
const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing'
saved.membership.completeness === 'incomplete'
? null
: saved.accounts.map((account) => ({
id: account.id,
@@ -30,6 +35,8 @@ export class BrokerageAccountsService {
}
: null,
}));
// The read-only result of the last hourly walk; never starts one.
const hourly = await this.sync.hourly(userId);
return {
data,
meta: investmentResourceMeta({
@@ -48,18 +55,25 @@ export class BrokerageAccountsService {
availability:
data === null
? 'missing'
: saved.accounts.some((account) => !account.snapshot)
? 'partial'
: 'complete',
: 'complete',
fetchedAt: saved.membership.lastSuccessfulAt
? new Date(saved.membership.lastSuccessfulAt)
: null,
freshForMs: 300_000,
work: saved.membership.sync.id
? { ...saved.membership.sync, id: saved.membership.sync.id }
: null,
freshForMs: 3_600_000,
work: null,
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,
};
}
}
@@ -1,14 +1,16 @@
import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import {
IncomeAnalyticsResourceDto,
IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto,
IncomeSummaryResourceDto,
} from './income-analytics.dto';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
@ApiTags('brokerage-accounts')
@@ -1,13 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
import { MoneyDto } from '../../../common/money.dto';
import {
InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
@@ -102,16 +102,16 @@ export class IncomeInstrumentDto {
declare key: string;
@ApiProperty({ type: String, nullable: true })
declare figi: string | null;
declare figi: null | string;
@ApiProperty({ type: String, nullable: true })
declare instrumentUid: string | null;
declare instrumentUid: null | string;
@ApiProperty({ type: String, nullable: true })
declare ticker: string | null;
declare ticker: null | string;
@ApiProperty({ type: String, nullable: true })
declare instrumentType: string | null;
declare instrumentType: null | string;
@ApiProperty()
declare displayName: string;
@@ -142,7 +142,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'week' | 'month';
declare bucketGranularity: 'day' | 'month' | 'week';
@ApiProperty({ type: IncomeBucketDto, isArray: true })
declare buckets: IncomeBucketDto[];
@@ -157,6 +157,7 @@ export class IncomeAnalyticsDto extends IncomeSummaryDto {
export class IncomeSummaryResourceDto {
@ApiProperty({ type: IncomeSummaryDto, nullable: true })
declare data: IncomeSummaryDto | null;
@ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto;
}
@@ -169,6 +170,7 @@ export class IncomeDependencyDto extends InvestmentDependencyDto {
}
export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-matches-property-optionality -- Overrides optional dependencies inherited from InvestmentResourceMetaDto.
@ApiProperty({
type: IncomeDependencyDto,
isArray: true,
@@ -182,6 +184,7 @@ export class IncomeAnalyticsMetaDto extends InvestmentResourceMetaDto {
export class IncomeAnalyticsResourceDto {
@ApiProperty({ type: IncomeAnalyticsDto, nullable: true })
declare data: IncomeAnalyticsDto | null;
@ApiProperty({ type: IncomeAnalyticsMetaDto })
declare meta: IncomeAnalyticsMetaDto;
}
@@ -6,8 +6,9 @@ jest.mock('../operations/application/brokerage-operations.service', () => ({
}));
import assert from 'node:assert/strict';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { BrokerageOperationDto } from '../operations/presentation/brokerage-operation.dto';
import { IncomeAnalyticsService } from './income-analytics.service';
describe('IncomeAnalyticsService', () => {
@@ -47,7 +48,7 @@ describe('IncomeAnalyticsService', () => {
expect.objectContaining({ role: 'previous' }),
expect.objectContaining({
role: 'year',
availability: 'missing',
availability: 'temporarily-unavailable',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}),
]);
@@ -188,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
).toEqual(['10.25', '-0.25']);
});
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
seed([
{
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
occurredAt: '2026-01-14T20:59:59.999Z',
},
{
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
occurredAt: '2026-01-14T21:00:00.000Z',
},
{
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
occurredAt: '2026-01-15T20:59:59.999Z',
},
]);
const response = await service.analytics(
'user',
'account',
'2026-01-14',
'2026-01-16',
);
expect(response.data?.buckets).toEqual([
{
period: { from: '2026-01-14', to: '2026-01-14' },
totalsByCurrency: [expect.objectContaining({
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-15', to: '2026-01-15' },
totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-16', to: '2026-01-16' },
totalsByCurrency: [],
},
]);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(

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