61 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
codex-bot ca5dbcb382 fix: harden auth and investment synchronization (#38)
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 11m48s
2026-09-27 14:32:06 +03:00
ksv741 4796d9d3f0 fix: harden auth and investment synchronization
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Successful in 11m9s
Conventional commits / commits (pull_request) Successful in 28s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 10m38s
Resolve A01-A10 and C01-C07 from the backend audit.
Preserve auth failures, forced candle reconciliation and forecast freshness.
Validate MOEX responses and nested HTTP errors; remove unused legacy paths.

Verified: 209 unit tests, 262 SQLite e2e tests on Linux, OpenAPI and durability.
Record the remaining macOS native SQLite abort in the audit.
2026-09-27 14:09:05 +03:00
codex-bot 5d3835e796 fix(investments): align roles and date contracts (#37)
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 11m33s
2026-09-27 12:05:57 +03:00
ksv741 516e43203e fix(investments): align roles and date contracts
Backend security / security (push) Successful in 9s
Backend verify / verify (push) Successful in 10m23s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m39s
Conventional commits / commits (pull_request) Successful in 29s
Identify income dependencies by role and document screen-specific dates.
Convert inclusive calendar days into half-open source ranges while
preserving operation timestamps and existing stored coverage.
2026-09-27 11:38:13 +03:00
235 changed files with 11396 additions and 5668 deletions
+25 -13
View File
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json` Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers. для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
request context и ограничивает поля событий. Jest unit tests, изолированные context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения. SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники). Локальные источники выбираются через [Источники](../AGENTS.md#источники).
@@ -160,8 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими `*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистый `integrations/tbank/decimal.ts` — отдельный API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
публичный вход для protobuf conversion: импорт арифметики не загружает DI/Prisma. `integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
публичные входы для protobuf conversion, protocol/error vocabulary и interval
rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты. Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks. Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются Новый export требует текущего потребителя; controller и persistence остаются
@@ -233,8 +236,8 @@ protobuf units/nano conversion — T-Bank integration. Domain используе
Общий выбор symbolic values и числовых policies — Общий выбор symbolic values и числовых policies —
[Domain values](conventions/engineering.md#domain-values). [Domain values](conventions/engineering.md#domain-values).
Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай Сохраняй strict TS и explicit boundary types. Для недоверенного input выбирай
`unknown` + runtime narrowing; отключённый lint `no-explicit-any` не делает `unknown` + runtime narrowing; ESLint запрещает explicit `any`
`any` нормой domain/application model. `declare` допустим для заполняемых извне в domain/application model, тестах и конфигурации TypeScript. `declare` допустим для заполняемых извне
DTO fields, не для сокрытия неинициализированного owned state. DTO fields, не для сокрытия неинициализированного owned state.
OpenAPI enum metadata и runtime validation должны читать тот же vocabulary, OpenAPI enum metadata и runtime validation должны читать тот же vocabulary,
@@ -244,11 +247,17 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
сохраняй; generated Prisma/protocol code вручную не меняй. сохраняй; generated Prisma/protocol code вручную не меняй.
`@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule `@typescript-eslint/no-magic-numbers` включён как error; базовое ESLint rule
отключено в пользу TS-версии. Исключения для очевидных чисел и их bigint форм, отключено в пользу TS-версии. Общего списка `ignore` для чисел и bigint нет:
индексов, default values, enum/type declarations и readonly properties заданы значения, проверяемые правилом, получают предметные имена у владельца.
в ESLint config. Test fixtures/expected values остаются inline, остальные lint Исключения для индексов, default values, enum/type declarations и readonly
правила продолжают действовать. Numeric policy получает предметное имя и единицы; properties заданы в ESLint config. Test fixtures/expected values остаются inline,
semantic magic strings проверяются conventions/review без нового инструмента. остальные 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 ## Logging
@@ -258,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
## Проверка ## Проверка
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case. `npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
Смысл boundaries проверяется review: backend не имеет отдельного автоматического ESLint проверяет прямые imports: domain не зависит от transport/persistence,
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner. controllers делегируют application, use cases не импортируют Prisma. Чистые
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
+31 -13
View File
@@ -7,11 +7,19 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу. Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`. начинай после явного решения Change Owner.
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
не используй системный Node.
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы. Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
## Change scope ## Change scope
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values ## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner. Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union. Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную. Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit. обычные labels не выноси в constants без semantic benefit.
@@ -56,19 +65,28 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные | Когда | Что проверить |
risk checks из местной testing convention. Targeted tests ускоряют итерацию, | --- | --- |
но не заменяют финальный gate. `lint:fix`/format — edits, не proof. | Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты | Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
чтения и `git diff --check`; application build ради текста не нужен. | Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты | Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
и ограничения. Зелёный lint не заменяет semantic/security review.
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
проверенные входы, команду, exit code, результат и ограничения. Повторяй
проверку при изменении значимых входов (включая config, dependencies и
generated artifacts), сомнении в evidence или явном запросе пользователя.
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
## Git и релизы ## Git и релизы
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`. Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
Сохраняй чужие изменения; перед commit просмотри staged paths и diff. Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
содержит одну связанную задачу. Root другого checkout не управляет этим repo. `spec:`. Каждый commit содержит одну задачу. Исключения release и правила
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+2 -2
View File
@@ -8,8 +8,8 @@ application/domain outcome → application mapping → HttpExceptionFilter
``` ```
Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`, Сохраняй принятый профиль RFC 9457: `status`, стабильный публичный `code`,
для validation — `errors: [{ pointer, detail }]`. Точный контракт и известное для validation — `errors: [{ pointer, detail }]`. Точный контракт validation
расхождение validation описаны в [HTTP API](http-api.md#источник-контракта). описан в [HTTP API](http-api.md#источник-контракта).
Не добавляй `type/title/instance` как обязательные поля без contract change. Не добавляй `type/title/instance` как обязательные поля без contract change.
Чистый domain не импортирует Nest HTTP exceptions. Существующий application Чистый domain не импортирует Nest HTTP exceptions. Существующий application
+3 -3
View File
@@ -83,6 +83,6 @@ artifact отдельно; backend не читает их репозитории
HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`. HTTP prefix — `/api/v1`; Swagger доступен согласно `src/config/swagger.config.ts`.
Общий error response — `application/problem+json` со `status` и публичным `code`. Общий error response — `application/problem+json` со `status` и публичным `code`.
Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`. Validation требует `400 / VALIDATION_ERROR` и `errors: [{ pointer, detail }]`.
Известное ограничение: ValidationPipe сейчас формирует строковые сообщения, ValidationPipe сохраняет структурированные field errors без `target/value`,
а filter ожидает структурированные field errors; schema parity не доказывает а filter формирует JSON pointers, включая вложенные поля. Schema parity не
правильность `errors`. Изменение требует проверки реального HTTP pipeline. доказывает правильность `errors`; изменения проверяются через реальный HTTP pipeline.
+7 -1
View File
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
Преобразование provider records происходит на boundary: например, Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper `tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
не должны загружать DI/Prisma через service exports. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов. на границе клиента/его mapping, а не становятся моделью чистых расчётов.
@@ -41,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и [validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts). [TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client. gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC; - `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой. `warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion. Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный - Native `nestjs-pino` context и `pino-http` completion владеют correlation:
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно. server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service. Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords, - StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data. allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace 401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе. сессию и очищает refresh cookie на HTTP-границе.
+3 -1
View File
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build. Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change. `npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner; `npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную `npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов. базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error` `prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска — на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных). [Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
-26
View File
@@ -1,26 +0,0 @@
name: Conventional commits
on:
pull_request:
types: [opened, synchronize, reopened, edited]
jobs:
commits:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
-31
View File
@@ -1,31 +0,0 @@
name: Backend security
on:
push:
branches:
- '**'
pull_request:
schedule:
- cron: '17 5 * * *'
jobs:
security:
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low
+68 -1
View File
@@ -2,11 +2,78 @@ name: Backend verify
on: on:
push: push:
branches: ['**'] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened]
schedule:
- cron: '47 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs: jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
else
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security:
needs: commits
if: >-
!cancelled() &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
fetch-depth: 0
- name: Record checked revision
run: git rev-parse HEAD
- name: Scan history with Gitleaks 8.30.1
run: |
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
mkdir -p /tmp/gitleaks-bin
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- name: Audit runtime and development dependencies
run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: https://github.com/actions/checkout@v4 - uses: https://github.com/actions/checkout@v4
-4
View File
@@ -1,4 +0,0 @@
{
"singleQuote": true,
"trailingComma": "all"
}
+3
View File
@@ -4,6 +4,9 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем [порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
объединяй triggers задачи и путей; читай активированные темы до решения. объединяй triggers задачи и путей; читай активированные темы до решения.
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
(main#28; после merge — актуальная версия `develop`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+673
View File
@@ -0,0 +1,673 @@
# Аудит backend WealthLedger и план исправлений
Дата аудита: 2026-09-27. Репозиторий: `/Users/ksv741/Project/vibe/WealthLedger/backend`.
База: `516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка `codex/investment-contracts`.
При аудите рабочее дерево было чистым; production-код не изменялся.
Этот документ сохраняет результаты аудита и задаёт проверяемый план исправлений.
Само сохранение документа не означает выполнения исправлений.
Номера строк относятся к указанному commit. Перед изменением повторно найти
symbol и проверить актуальный flow: после предыдущих исправлений строки сдвинутся.
Все относительные пути и ссылки относятся только к backend.
## Общая оценка
Backend имеет понятных владельцев ответственности: контроллеры тонкие,
денежные расчёты преимущественно точные, транзакции и очереди сосредоточены
у своих владельцев. Массовое дробление файлов не требуется.
Наиболее проблемные области — обработка ошибок auth, проверка ответов MOEX,
согласование состояния фоновой синхронизации и временных признаков кешированных данных.
Изучены локальные [AGENTS.md](AGENTS.md), [порядок работы](.ai/conventions/engineering.md),
[архитектура](.ai/architecture.md) и активированные темы HTTP, errors, data,
testing, security, integrations и observability. Соседние репозитории не использовались.
Инвентаризированы 135 production-файлов TypeScript, 16 004 строки, 17 Nest module-файлов.
Generated Prisma, vendor protocols, миграции и тесты отделены от production-рейтинга.
Приоритеты: **высокий** — существенный риск безопасности или искажения данных;
**средний** — ограниченный дефект поведения, контракта или доказанная стоимость связности;
**низкий** — локальное упрощение. Уверенность всех перечисленных находок высокая
для описанного механизма; ограничения конкретного воспроизведения указаны отдельно.
## План выполнения и границы
1. Прочитать действующий backend AGENTS и активированные conventions; зафиксировать
текущие HEAD/status. Сохранить существующие staged/unstaged/untracked изменения.
2. Работать по находкам A01–A10 и cleanup C01–C07 ниже, в порядке пакетов из таблицы.
Перед каждым исправлением перечитать владельца целиком, callers, связанные tests
и 1–3 релевантных примера. Если факт больше не воспроизводится, отметить это
с доказательством вместо механического применения рекомендации.
3. Для дефекта сначала получить падающую проверку наблюдаемого результата на самой
дешёвой надёжной boundary. Для concurrency/atomicity использовать реальную
изолированную SQLite; mock race не доказывает DB-инвариант.
4. Исправлять у существующего владельца минимальным связанным diff. Сохранять
precision, ownership, public error codes, transaction/fencing, cache isolation,
limiter и выполняющиеся promises. Предложения ниже задают результат, а не
обязательную форму реализации.
5. После каждого пакета выполнить targeted checks; в конце — актуальные обязательные
gates по backend conventions. Перед каждой Node-командой выполнить `nvm use --silent`.
6. Сохранить исходный снимок аудита; результаты реализации записывать в таблицу
«Статус исправлений». Для каждого ID указать diff/решение, команды и результат,
остаточный риск либо точную причину невыполнения.
| Пакет | Пункты | Условие завершения |
| --- | --- | --- |
| 1. Auth и безопасные ошибки | A01, A02 | Отказы хранилища не маскируются; секреты не попадают в completion log |
| 2. MOEX trust boundary | A03 | Malformed response не публикуется как полный результат; pagination завершается |
| 3. Статус синхронизации | A04 | Screen учитывает состояние successor через владельца operations |
| 4. Свечи | A05 | Force не теряется при coalescing; forced publication остаётся атомарной |
| 5. Свежесть и время | A06, A07 | Stale-флаг актуален; wire timestamp совпадает с используемым при фильтрации |
| 6. HTTP validation | A08 | Реальный pipeline возвращает структурированные field errors |
| 7. Связность и удаление legacy | A09, A10, C01–C07 | Живые boundaries сохранены; лишние реализации и exports удалены |
Пакеты независимы по цели, но могут пересекаться файлами и общими fixtures.
Параллелить только области с непересекающимся владением файлами, затем выполнить
общую проверку. A02 и A08 изменяют один HTTP filter и должны быть согласованы.
В scope входят исправления подтверждённых находок и их regression tests.
Гипотезы в конце документа не являются задачами на изменение кода.
Новые зависимости, generic repositories, scheduler framework, интерфейсы ради
паттерна, schema migrations и size-only splits для этих исправлений не требуются.
Отдельную необходимость такого расширения следует доказать до реализации.
Коммиты, push и PR выполняются только по отдельному запросу пользователя.
## Подтверждённые находки
### A01. Auth lifecycle смешивает invalid credentials и operational failure
**Приоритет:** высокий. **Уверенность:** высокая.
**Места и потребители:** [AuthService.logout, строки 104–126](src/modules/auth/application/auth.service.ts),
[AuthController.logout, 95–104; refresh, 85–91](src/modules/auth/presentation/auth.controller.ts).
Общий catch в logout подавляет JWT verification errors и отказ Prisma при записи
`revokedAt`. Controller затем очищает cookie и возвращает 204, хотя серверная
сессия осталась действительной. Сохранённая копия refresh token продолжает работать.
Смежное проявление: refresh controller удаляет cookie при любой ошибке, включая
временный отказ БД, без доказательства недействительности токена.
**Доказательство:** inline fault injection на настоящих AuthService, JwtService
и AuthController с синтетической сессией подтвердил:
```text
logoutResolved=true
cookieCleared=true
sessionNotRevoked=true
sameRefreshTokenStillUsable=true
unexpectedRefreshErrorPropagated=true
validCookieRemoved=true
```
Это доказательство обработки injected boundary failure, а не реального отказа БД.
[Auth HTTP suite](test/app.e2e-spec.ts) проверяет обычный logout, но не этот отказ.
Нарушен [контракт отзыва сессии](.ai/conventions/security-auth.md): идемпотентность
не превращает несостоявшуюся запись в успешную.
**Минимальное действие:** ограничить подавление ошибок logout недействительным JWT;
ошибки хранилища пробрасывать. При refresh очищать cookie только при установленном
auth failure. Существующий AuthService.refresh уже отделяет verification от DB reads;
BankCardsRepository.write также пробрасывает неизвестные ошибки.
**Приёмка:**
- Отсутствующий/недействительный/уже отозванный refresh сохраняет идемпотентный logout.
- Ошибка записи revocation не возвращает фиктивный 204.
- Успешный logout делает последующий refresh невозможным.
- Временный отказ refresh не удаляет действующую cookie, повтор после восстановления возможен.
- Проверены status/code/cookie через configured HTTP; успешный отзыв — с SQLite.
**Риск:** сохранить существующую семантику недействительных токенов. Изменение
access-JWT lifetime или введение replay history не входят в этот пункт.
### A02. Многострочное сообщение исключения попадает в completion log
**Приоритет:** высокий. **Уверенность:** высокая.
**Места:** [HttpExceptionFilter, 30–37](src/common/filters/http-exception.filter.ts).
**Потребители:** [RequestLoggingMiddleware, 43–57](src/common/logging/request-logging.middleware.ts),
[StructuredLogger, 47–63](src/common/logging/structured-logger.service.ts).
Фильтр удаляет только первую строку `exception.stack`. Остальные строки
многострочного Error.message сохраняются. Поле stack разрешено logger allowlist;
redaction HTTP body к нему не применяется.
**Воспроизведение:** configured HTTP app; AuthService.login выбрасывает синтетический
`Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET')`. Ответ остаётся
`500 / INTERNAL_SERVER_ERROR`, но completion log содержит вторую строку сообщения.
Реальные секреты не использовались. Существующий filter spec проверяет однострочное сообщение.
Нарушены [observability](.ai/conventions/observability.md) и security rules.
**Минимальное действие:** убрать передачу сырого stack через общую HTTP boundary,
сохранив code/request context. Если stack необходим, нужна проверенная форма кадров
без произвольного текста исключения; удаление только первой строки недостаточно.
**Приёмка:** multiline synthetic secrets отсутствуют в response и в конечном
payload logger, а status/code/correlation сохраняются. Проверить настоящий
filter → middleware → logger, не только mock вызова filter.
**Риск:** диагностическая подробность может уменьшиться; raw message возвращать нельзя.
### A03. MOEX malformed response превращается в полный пустой прогноз
**Приоритет:** высокий. **Уверенность:** высокая.
**Место:** [MoexClient, 122–142 и 163–165](src/integrations/moex/moex.client.ts).
Отсутствующий запрошенный ISS block преобразуется в `[]`. Неполный cursor может
завершить чтение с частичными данными; cursor с нулевым pageSize может зациклить его.
**Реальная цепочка:** [CatalogEnrichmentService, 444 и 509–548](src/modules/investments/instruments/application/catalog-enrichment.service.ts)
публикует `bondEvents: []`; readMany считает существующий row complete;
[UpcomingEventsService, 137–197](src/modules/investments/upcoming-events/upcoming-events.service.ts)
строит пустые events/totals; [ForecastRepository, 124–150](src/modules/investments/portfolio/infrastructure/forecast.repository.ts)
может заменить прежний непустой forecast. Владелец protocol validation — integration client.
**Воспроизведение:** настоящий MoexClient с mock fetch HTTP 200 без coupons/offers
вернул `[]`. Cursor `INDEX=0,TOTAL=1,PAGESIZE=0` дал пять запросов `start=0`;
проба остановлена искусственной ошибкой. Timeout каждого fetch не ограничивает весь цикл.
**Расхождение тестов:** [moex.client.spec, 97–107](src/integrations/moex/moex.client.spec.ts)
считает отсутствующий block пустым результатом;
[upcoming-events.e2e, 439–451](test/upcoming-events.e2e-spec.ts) обещает обратное,
но подменяет весь клиент готовым rejection и не проверяет parser.
**Минимальное действие:** различать отсутствующий/повреждённый block и корректный
`data: []`; проверять структуру строк и cursor, положительный pageSize и строгое
продвижение start. Нарушение протокола → `MOEX_UNAVAILABLE`, без частичного success.
**Приёмка:**
- Malformed block/cursor не меняет сохранённый полный календарь/forecast.
- Корректный пустой block остаётся успешным пустым результатом.
- Непродвигающаяся pagination завершается ошибкой, без бесконечных fetch.
- Семантика HTTP 404 проверена отдельно и не изменена случайно.
- Downstream regression использует настоящий MoexClient с mock fetch, а не mock rejection клиента.
**Риск:** перепутать законное отсутствие данных с ошибкой protocol shape.
### A04. Screen sync сообщает succeeded до завершения successor
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [TbankSyncService.status, 330–345](src/modules/investments/tbank-sync/tbank-sync.service.ts),
[TbankSyncRepository.read, 70–80](src/modules/investments/tbank-sync/tbank-sync.repository.ts).
[Operations repository, 441–445](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
помечает объединяемую группу `superseded` и сохраняет successorId. Владелец операций
умеет разрешать цепочку в observe, строки 564–590, но screen читает прямого child.
**Сценарий:** две активные screen-группы на части января; третья объединяет интервалы.
Screen вытесненной группы сообщает succeeded, хотя successor ещё running; его
последующая ошибка также теряется.
**Воспроизведение настоящего status с mocked persistence:**
```text
screenState=succeeded
resource.availability=missing
resource.sync.state=running
actualOperationState=running
```
SQLite bridge покрыт [operations-sync e2e, 455–474](test/tbank-operations-sync.e2e-spec.ts),
но существующая проверка наблюдает operations.observe, а не screen status.
**Минимальное действие:** разрешать operations successors через существующего
владельца перед агрегацией screen state/progress. Не копировать второй алгоритм
разрешения цепочки в screen service.
**Приёмка:** configured HTTP + SQLite bridge; задержанный successor → running,
затем успех → succeeded или отказ → failed; progress согласован, чужая revision
не видна. **Риск:** fencing, цепочки successors и корректный подсчёт progress.
### A05. Force обновления свечей теряется при coalescing
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [CandleService.request/load, 326–365](src/modules/investments/instruments/application/candle.service.ts),
[CandleRepository.reserve, 76–82](src/modules/investments/instruments/infrastructure/candle.repository.ts).
Публичный caller: [TbankSyncService, 288–306](src/modules/investments/tbank-sync/tbank-sync.service.ts)
вызывает candles.request с force=true; candleRange необязателен, ключ может совпасть
с ordinary GET/prefetch.
При launch=false service возвращает существующий id без сохранения новых force/priority.
При присоединении cross-user observer требования также не повышаются.
**Воспроизведение настоящего класса с mocked repository/upstream:** январь покрыт,
ordinary period=all заблокирован перед загрузкой detail, затем приходит force all.
После release единственный RPC идёт от `2026-02-01T00:00:00.000Z` до
`2026-02-02T12:00:00.000Z`; январь не перечитан. Исправленные исторические значения
остаются прежними. Existing candle suite отдельно проверяет force и coalescing.
**Минимальное действие:** сохранить forced требование и выполнить полную reconciliation.
Если gap-план уже начат, безопасно перепланировать или выполнить следующую forced
попытку. Простого `force ||= true` после публикации первых окон недостаточно.
Аналог повышения требований есть в CatalogDiscoveryService, но его алгоритм нельзя
механически переносить на staged свечи.
**Приёмка:** ordinary load + force того же scope действительно обновляют уже
закрытую свечу. Возвращённый work ID завершается только после выполнения forced
требования с актуальным диапазоном запроса. Проверить join до запуска и после
публикации первого обычного окна, включая observer другого пользователя;
экранный запрос получает экранный приоритет. Ошибка следующего forced окна сохраняет
прежнюю историю и timestamps; user/revision fencing сохраняется.
Proof атомарности — существующая SQLite boundary.
**Риск:** высокий риск неверного локального исправления из-за staged/atomic publication.
### A06. Payout snapshot замораживает positionIsStale
**Приоритет:** средний. **Уверенность:** высокая.
**Место:** [ForecastRepository.resolve, 132–137](src/modules/investments/portfolio/infrastructure/forecast.repository.ts).
**Потребители:** [StockIncomeService, 67–87 и 161–163](src/modules/investments/instruments/stocks/application/stock-income.service.ts),
[BondIncomeService, 180–182](src/modules/investments/instruments/bonds/application/bond-income.service.ts).
Истечение 60-секундного portfolio TTL меняет isStale, но не source revision/timestamps.
Repository возвращает прежний payload вместо нового candidate. Возможны одновременно
`meta.freshness: stale` и `data.positionIsStale: false`.
**Воспроизведение:** настоящий repository с mocked transaction; candidate false,
затем true при тех же версиях → повторный результат false, всего одна запись.
Pure payout tests принимают stale input, но не проверяют aging persisted forecast.
**Минимальное действие:** вычислять возраст сохранённой позиции при выдаче по её
positionAt и действующей freshness policy. Учитывать именно позицию сохранённого
fallback, а не случайно подставлять свежесть более нового несовместимого портфеля.
Не сравнивать весь candidate без разбора: текущий calculatedAt у events вызовет
запись на каждом чтении. Не менять financial revision только из-за хода времени.
**Приёмка:** controllable clock, TTL истёк без изменения источников, sync=observe:
stale=true, суммы и source timestamps прежние, новых RPC нет; fallback остаётся
согласованным. **Риск:** смешение возраста сохранённой и текущей позиции.
### A07. Timestamp операций расходится между persistence и wire
**Приоритет:** средний. **Уверенность:** высокая.
**Место:** [brokerage-operation.mapper, 85–90](src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts)
игнорирует nanos; [repository, 653–657 и publish](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts)
учитывает миллисекунды. Consumers mapper: BrokerageOperationsService, обычный ledger
и income reads (строки 359 и 619 на базе аудита).
**Воспроизведение:** seconds=1768000000, nanos=600000000 → persistence
`2026-01-09T23:06:40.600Z`, wire `2026-01-09T23:06:40.000Z`.
Запрос from=`…40.500Z` возвращает строку с видимым временем раньше from.
Existing mapper specs проверяют money/quantity, но не это соответствие.
**Минимальное действие:** согласовать конвертацию protobuf timestamp; при возможности
использовать уже сохранённый occurredAt для wire. Предотвратить дальнейшее расхождение
реализаций без создания универсальной библиотеки дат.
**Приёмка:** subsecond mapper case и HTTP boundary внутри секунды; порядок/cursor,
обычный ledger и income используют согласованное время. **Риск:** исправляется
публичное значение timestamp; формат и точность Money сохраняются.
### A08. Configured validation response теряет ошибки полей
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [configureApp, 29](src/main.ts),
[HttpExceptionFilter, 44–48 и 70–78](src/common/filters/http-exception.filter.ts).
ValidationPipe выдаёт message:string[], filter приводит его к ValidationError[]
и ищет constraints. children вложенных ошибок также не обходятся.
**Configured HTTP reproduction:** invalid email и отсутствующий password:
```json
{"status":400,"code":"VALIDATION_ERROR","errors":[]}
```
Ожидаемый [HTTP contract](.ai/conventions/http-api.md) требует pointer/detail.
Conventions уже фиксируют это известное расхождение. OpenAPI parity проверяет
schema, но не наблюдаемый response pipeline.
**Минимальное действие:** согласовать exceptionFactory и filter, сформировать
структурированные ошибки, включая вложенные поля, без target/value исходного DTO.
**Приёмка:** реальные requests credentials и nested Money дают непустые корректные
pointer/detail; status/code/content-type сохраняются; неизвестные исключения
по-прежнему безопасны. Проверить согласование с A02 и публичной metadata.
**Риск:** public error shape и корректность вложенных путей.
### A09. Planner зависит от service barrel и образует runtime import cycle
**Приоритет:** средний. **Уверенность:** высокая.
**Места:** [investment-sync-plan, 3–6](src/modules/investments/investment-sync-plan.ts)
→ [operations.api](src/modules/investments/operations/operations.api.ts)
→ operations services → investment-sync-plan.
Алгоритму нужны interval rules, но barrel загружает services, TbankClient и Prisma.
[Planner spec, строка 1](src/modules/investments/investment-sync-plan.spec.ts) уже
вынужден mock Prisma. Это доказанная стоимость связности; production crash из-за
цикла не обнаружен. Type-only цикл T-Bank records — отдельный безвредный случай.
**Минимальное действие:** предоставить interval rules чистый публичный вход,
аналогичный разрешённому integrations/tbank/decimal.ts; service API оставить отдельно.
Не заменять cycle на forwardRef и не вводить новый DI слой.
**Приёмка:** planner импортируется/тестируется без Prisma mock; cycle устранён,
interval semantics и DI initialization сохранены. **Риск:** низкий, import/export surface.
### A10. Неиспользуемый legacy T-Bank access flow
**Приоритет:** низкий. **Уверенность:** высокая.
**Место:** [TbankAccessService, 255–330](src/integrations/tbank/tbank-access.service.ts).
listSupportedAccounts/withSupportedAccounts/withConnection/withAccount/
withAccountForConnection/private connection вызываются только внутри собственной
цепочки и tests. Проверены production consumers, DI и decorators.
Legacy flow поддерживает generation=hash(ciphertext), тогда как живой persisted
flow использует DB revision, local и tokenForAttempt. Поддерживаются две модели
без production-потребности; около 76 строк, context types и TbankClient dependency.
**Минимальное действие:** повторно проверить callers на актуальном HEAD, удалить
legacy-цепочку, неиспользуемые context exports и зависимость; отдельно проверить
оставшихся consumers accessibleTbankAccount до его удаления.
**Приёмка:** нет мёртвой цепочки, живые access/invalid-token/revision/fencing cases
сохранены. Перенести полезные security assertions, если они были только в удаляемых
specs. **Риск:** удалить общее правило или полезную проверку вместе с legacy.
## Локальные упрощения
Все пункты: низкий приоритет, высокая уверенность. Перед удалением повторить поиск
callers; отсутствие production callers в базе аудита не гарантирует его на новом HEAD.
| ID | Место и доказательство | Минимальное действие и приёмка |
| --- | --- | --- |
| C01 | [portfolio.service, 515–522](src/modules/investments/portfolio/application/portfolio.service.ts): локальный decimalFromString повторяет common parser без validation; callers position/value | Импортировать существующий common owner; удалить дубликат. Exact-money tests и typecheck. Текущая ошибка сумм не доказана; проверить допустимые входы. |
| C02 | [tbank.api](src/integrations/tbank/tbank.api.ts): TbankSyncAttempt импортируют напрямую семь внешних файлов sync/instruments; enrichment также обходит API для market types | Экспортировать используемые типы, перенаправить imports через API. TbankCoupon уже экспортируется. Typecheck и поиск обходов; runtime-риск минимален. |
| C03 | [auth.service, 190–192](src/modules/auth/application/auth.service.ts): normalizeEmail возвращает аргумент; register/login вызывают его, реальная нормализация в CredentialsDto | Удалить no-op, сохранить DTO transform. Existing HTTP case/whitespace tests; дополнительную нормализацию без необходимости не вводить. |
| C04 | [bank-card.calculations, 56–76](src/modules/banking/bank-cards/domain/bank-card.calculations.ts): string period используется только tests; production summary передаёт from/to | Сузить API до рабочего диапазона; перевести полезные unit cases. Проверить границы месяца/range. |
| C05 | [instrument-market, 39–51](src/modules/investments/instruments/application/instrument-market.ts): optional без consumers; [stock-position wrapper](src/modules/investments/instruments/stocks/application/stock-position.ts) нужен только своему spec | Удалить optional, перенаправить полезные wrapper tests на instrumentPosition. Сохранить fractional/mixed/offset holdings cases. |
| C06 | [portfolio.api, 2](src/modules/investments/portfolio/portfolio.api.ts): totalsByCurrency не имеет внешнего production consumer; внутренний использует mapper напрямую | Удалить только лишний export, mapper сохранить; typecheck. |
| C07 | [auth.controller.spec, 9–20](src/modules/auth/presentation/auth.controller.spec.ts): проверяет HTTP_CODE_METADATA через prototype; те же 200 проверяются configured HTTP suite | Удалить дублирующий wiring spec только при сохранении реальных HTTP status/cookie assertions. |
## Карта ответственности и проверенные сценарии
| Область / владелец | Интерфейс и consumers | Состояние, зависимости, инварианты и эффекты |
| --- | --- | --- |
| App/config/common/Prisma | Composition root, configureApp, Money/decimal, logging | Env validation, HTTP pipeline, DB lifecycle, request context; общая арифметика без Nest/Prisma |
| Auth | Auth HTTP; guard/AccessPayload API для protected controllers | User/Session, bcrypt/JWT, refresh hash/CAS; cookies у controller |
| Bank cards/operations/categories | BankCardsController → профильные services | Ownership, archive/version/replay/tombstones; refund budget и перевод в общей transaction boundary |
| Bank summary | Controller → use case → repository → pure calculation | Согласованное чтение и BigInt-сводка по периоду |
| Brokerage accounts | HTTP/read API для screen → service → PortfolioService | Собственной persistence нет; missing membership отличается от confirmed empty |
| Portfolio/forecast | HTTP и API для instruments/events/accounts | Private user/revision/account snapshots; mapping/decimal; aggregate completeness; atomic dependency version checks |
| Operations | HTTP/API для income/screen | Local journal, signed cursor binding; durable work/staging, intervals, recovery, atomic reconciliation |
| Income analytics | HTTP/read API → Operations API | Period roles/completeness, grouping и точные signed totals по валютам; своей DB нет |
| Upcoming events | HTTP/read API → portfolio/calendars → forecast | Events по positions; missing calendar не равен пустому; redemption исключён из income totals |
| Catalog discovery/enrichment | Stock/bond services и public source APIs | Shared identity/aliases/source rows; private work, TTL, batching, coalescing и generation-fenced publication |
| Candles | Market services/screen → CandleService/Repository | Shared candles/coverage; mutable tail, gaps, windows, очередь и staged force replacement |
| Stock/bond position/income | Controllers → application services | UID/type matching, exact weighted cost, mixed currencies, payout estimates; Portfolio/Catalog/Forecast APIs |
| T-Bank connection | Guarded GET/PUT/DELETE | Проверка токена до replacement, AES-GCM, новая revision/cascade; failed validation сохраняет connection |
| T-Bank client/access/membership/metadata | Integration API для business modules | gRPC/TLS, единые limiter/retry/deadline; credentials, account access, membership и publication fencing |
| Screen sync | POST descriptor / GET status | Orchestration через APIs; durable children/attempts; passive observation |
| MOEX/NCR | Integration APIs → enrichment | ISS parser/pagination; NCR strict registry parser/exact ISIN/shared cache |
Прослежены auth/session flows; создание/изменение карт, операций, переводов,
возвратов и summary; ledger→sync→staging→publication; portfolio→mapping→snapshot;
payouts/events→forecast; catalog/candles и screen orchestration.
Повторной регистрации владельцев caches/queues и циклов Nest modules не обнаружено.
InstrumentSourcesModule позволяет portfolio пользоваться источниками без DI-цикла
с InstrumentsModule. Type-only цикл T-Bank records не является finding.
## Размеры production-файлов
Количество строк включает comments/blank lines. Размер — основание для чтения,
а не самостоятельный дефект. Обязательные splits не обоснованы.
| Файл | Строк | Решение и причина |
| --- | ---: | --- |
| [brokerage-operations.repository](src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts) | 924 | Оставить: overlap groups, staging, recovery и publication держат общий transactional invariant |
| [catalog-enrichment.service](src/modules/investments/instruments/application/catalog-enrichment.service.ts) | 665 | Упростить локально: protocol boundary/imports; сохранить producers, batching, completions вместе |
| [brokerage-operations.service](src/modules/investments/operations/application/brokerage-operations.service.ts) | 636 | Оставить: ledger/income имеют различную completeness; generic builder добавит скрытые режимы |
| [tbank-sync.service](src/modules/investments/tbank-sync/tbank-sync.service.ts) | 595 | Упростить: разрешение successors вернуть operations owner, generic job framework не нужен |
| [tbank.client](src/integrations/tbank/tbank.client.ts) | 560 | Оставить: records/RPC methods и единый limiter/retry owner |
| [portfolio.service](src/modules/investments/portfolio/application/portfolio.service.ts) | 522 | Упростить: удалить duplicate parser, scheduling state не разрывать |
| [candle.service](src/modules/investments/instruments/application/candle.service.ts) | 481 | Оставить: исправить force join, сохранить queue/publication invariant |
| [catalog-discovery.repository](src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts) | 480 | Оставить: identity/aliases/source publication и CAS связны |
| [catalog-discovery.service](src/modules/investments/instruments/application/catalog-discovery.service.ts) | 390 | Оставить: search/detail и producer lifecycle имеют одного owner |
| [bank-operations.service](src/modules/banking/bank-cards/application/bank-operations.service.ts) | 343 | Оставить: разрыв validation/refund/transfer writes повышает риск |
| [tbank-access.service](src/integrations/tbank/tbank-access.service.ts) | 331 | Упростить удалением legacy flow |
| [investment-sync-plan](src/modules/investments/investment-sync-plan.ts) | 327 | Упростить зависимость: убрать service barrel из алгоритма |
| [instrument-metadata.repository](src/integrations/tbank/instrument-metadata.repository.ts) | 320 | Оставить: identity, batching и publication согласованы |
| [income-analytics.service](src/modules/investments/income/income-analytics.service.ts) | 318 | Оставить: связные projections, выгода дополнительных моделей не доказана |
| [candle.repository](src/modules/investments/instruments/infrastructure/candle.repository.ts) | 306 | Оставить: reconciliation, coverage, fencing и stage cleanup |
| [upcoming-events.service](src/modules/investments/upcoming-events/upcoming-events.service.ts) | 297 | Оставить: один сценарий, основной дефект у MOEX boundary |
| [brokerage-operations-sync.service](src/modules/investments/operations/application/brokerage-operations-sync.service.ts) | 294 | Оставить: связный владелец durable work execution |
Отдельные категории: 37 unit specs / 3319 строк; 21 HTTP/SQLite specs / 7878 строк;
10 test support/fixtures / 1993 строки; 6 vendor proto / 3588 строк;
5 SQL migrations / 345 строк. Generated Prisma и build artifacts не ранжировались.
## Удачные решения, которые требуется сохранить
- BigInt/decimal и string Money; разные валюты не складываются неявно.
- Refresh CAS, ownership queries, версии операций, replay fingerprints/tombstones.
- Network calls отделены от DB transactions.
- Revision/attempt fencing, late-result discard, staging перед force reconciliation.
- Missing/partial/confirmed empty/saved complete различаются явно.
- Concrete providers, компактные плоские модули и разрешённый прямой Prisma
в связных CRUD/transaction services не являются автоматическими дефектами.
- SQLite behavior tests проверяют реальные денежные границы, races и rollback.
## Выполненные проверки и пределы доказательства
Перед успешными Node-командами загружалась `~/.nvm/nvm.sh`, затем `nvm use --silent`.
Первый вызов nvm без загрузки shell function завершился command not found;
после загрузки проверок это ограничение не осталось.
| Команда / проверка | Результат аудита |
| --- | --- |
| git status, git rev-parse HEAD, git diff --check | Чистое дерево, неизменный commit, diff errors отсутствуют |
| npm run lint | Успешно |
| npm run typecheck:app; npm run typecheck:test | Успешно |
| npm test -- --runInBand | 37 suites / 200 tests passed |
| Build в npm run test:e2e | Prisma generation и Nest build успешны |
| Полный e2e в sandbox | listen EPERM на localhost; не засчитан |
| LOG_LEVEL=error node test/sqlite.cjs --all, с разрешением localhost | all-accounts: 18 passed; auth-suite затем завершилась native SIGABRT |
| node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts tbank-operations-persistence.e2e-spec.ts tbank-candles-persistence.e2e-spec.ts tbank-metadata-persistence.e2e-spec.ts tbank-local-reads.e2e-spec.ts openapi-contract.e2e-spec.ts | Первые три: 27 + 5 + 9 passed; metadata native abort; последующие не достигнуты |
| node test/sqlite.cjs openapi-contract.e2e-spec.ts, отдельно | 3 tests passed |
| Inline reproductions | Подтверждены описанные случаи; validation/logging через configureApp, остальные с указанными mocked boundaries |
Native abort произошёл в `node::RemoveEnvironmentCleanupHook` / `better_sqlite3.node`
на Node 24.21.0. Причина не установлена; связывать его с конкретной source-находкой
нельзя. Полный e2e gate не подтверждён. При реализации сначала повторить в штатном
окружении; если проблема остаётся, диагностировать отдельно и явно сообщить,
не отключая падающие suites и не выдавая неполную проверку за зелёную.
Логи текущей сессии (временные файлы, могут исчезнуть):
`/tmp/wealthledger-audit-e2e.log`, `/tmp/wealthledger-audit-targeted-e2e.log`,
`/tmp/wealthledger-audit-openapi.log`. Документ не зависит от сохранности этих файлов.
**Проверено:** все production-модули, composition/API, основные consumers,
значимые flows, связанные tests кандидатов. Все production auth/banking,
integrations/sync/operations и остальные investment slices прочитаны.
**Частично проверено:** весь набор HTTP fixtures и integration suites не проходил
одинаково глубокий построчный аудит; реальное выполнение e2e ограничено указанным
native abort. Schema прочитана; история миграций не проходила полный compatibility audit.
**Не проверено:** clean npm ci, полный verify с coverage как единая команда,
durability/restart/backup, container/deployment, production performance,
реальные внешние API и полная сверка vendor proto с upstream.
### Финальная проверка реализации
Использовать актуальные scripts/conventions backend. На базе аудита для code changes
требуются npm ci, npm run verify, relevant SQLite/HTTP checks; для этих HTTP изменений
также OpenAPI generation/parity. Полный npm run test:e2e нужен для итоговой проверки
совокупных изменений. Перед Node — nvm use --silent.
Автоисправления lint/format не являются доказательством. Не запускать миграции,
reset/drop или tests на пользовательской БД. Штатный SQLite runner создаёт временные
базы и защищает от использования внешнего DATABASE_URL.
По завершении повторить routing по staged/unstaged/new files, выполнить Boy Scout
и semantic review: callers, owners, money/access/atomicity/error contracts,
generation/cache/async lifecycle. Указать точные команды, outcomes и ограничения.
## Неподтверждённые гипотезы — вне scope исправлений
- Logout/refresh interleaving и browser cookie overwrite требуют явных ordering
semantics и отдельного HTTP/browser proof.
- N+1 в карточных views виден, но измеренная latency/timeout проблема не установлена.
- JSON casts внутренних сохранённых payload сами по себе не доказывают небезопасную boundary.
- Cash/accrued-interest valuation: локального контракта недостаточно для обвинения в ошибке.
- Native SQLite abort требует отдельной диагностики runtime/dependency lifecycle.
## Статус исправлений
Начало исправлений: HEAD `516e43203eb3fee8e6a0c7018be2fee11f1b900c`,
ветка `codex/investment-contracts`; `git status --short`: только
`?? BACKEND_AUDIT.md`, staged/unstaged tracked changes отсутствовали.
Исходные разделы аудита сохранены. `npm ci` успешно: 1065 packages, 44s;
manifest/lockfile не менялись. Перед Node-командами загружается `~/.nvm/nvm.sh`
и выполняется `nvm use --silent`.
Все A01–A10 и C01–C07 повторно подтверждены и реализованы в порядке пакетов.
Статусы ниже разделяют готовность исправления и полноту проверки: native abort
не засчитывается как успешный behavioral test. Неподтвердившихся и уже исправленных
на исходном HEAD пунктов не обнаружено.
| ID | Статус | Изменение / доказательство | Проверки и ограничения |
| --- | --- | --- | --- |
| A01 | Исправлено и проверено | Logout подавляет только JWT verification failure; refresh удаляет cookie только при UnauthorizedException. Configured HTTP red: logout 204 вместо 500, refresh удалил cookie при отказе БД. | `node test/sqlite.cjs app.e2e-spec.ts`: regression воспроизведена на временной SQLite; macOS green повтор и первый полный e2e столкнулись с native SIGABRT. Итоговый Linux e2e: auth 12/12 passed, включая revocation failure и сохранение cookie для retry. |
| A02 | Исправлено и проверено | Удалены raw stack и его неиспользуемый transport/allowlist; regression проверяет multiline synthetic secret в конечном Pino payload и correlation. | Targeted filter/logging: 3 suites / 4 tests passed, unit red → green; macOS configured HTTP runner завершился native SIGABRT; итоговый Linux auth 12/12 passed, включая настоящий filter → middleware → Pino payload. |
| A03 | Исправлено и проверено | MoexClient отклоняет отсутствующий/повреждённый block, неверную ширину row, malformed/nonprogressing cursor. Пустые блоки и 404 сохранены. | Unit red 12 failures → 21 passed; `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: downstream red 2 failures → 18 passed, настоящий client + mock fetch сохраняют calendar/forecast payload/revisions/timestamps. Typecheck и scoped lint passed. |
| A04 | Исправлено и проверено | Screen status разрешает superseded child через operations.observe перед агрегацией state/progress; attempt/revision fencing сохранён. | `node test/sqlite.cjs tbank-screen-sync.e2e-spec.ts`: HTTP/SQLite red succeeded вместо running → 29 passed; successor success/failure, progress, чужой user и удалённая revision. |
| A05 | Исправлено и проверено | Каждый join повышает force/priority при более сильном требовании; идентичный join не меняет updatedAt. Load фиксирует force/now на проход, при новой forced потребности выполняет полную reconciliation. Финальная публикация и observers сериализованы с reservations; screen priority=0. | Service unit red → green (полный исторический диапазон и более поздний now); `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts`: 14 passed на финальном коде, same/cross-user join до загрузки и после обычного окна, rollback второго forced окна, timestamps и fencing. Native abort в первом финальном запуске; единственный повтор без изменений прошёл. |
| A06 | Исправлено и проверено | ForecastService вычисляет возвращаемый positionIsStale из сохранённого positionAt через существующую portfolio TTL policy; не меняет финансовый snapshot. | `node test/sqlite.cjs upcoming-events.e2e-spec.ts`: red stock/bond false на TTL → 20 passed; TTL−1/TTL, observe без RPC, прежние суммы/revisions/timestamps, fallback при несовместимом свежем candidate. Review дополнительно воспроизвёл meta.freshness=fresh для старого fallback (2 red); freshForMs теперь учитывает сохранённый stale flag, repeat 20 passed, stateVersion рассчитывается штатно. |
| A07 | Исправлено и проверено | Одна operationTimestamp conversion у mapper для stage/publish; ledger/income wire используют сохранённый occurredAt. Missing seconds по-прежнему отклоняется. | Unit red .600→.000 → 9 passed; `node test/sqlite.cjs tbank-ledger-local.e2e-spec.ts`: HTTP red → 11 passed (from=.500, cursor .800/.600, income timestamp/exact total). Оба typecheck/lint passed. На macOS operations-persistence завершилась native SIGABRT и не засчитана. Итоговый Linux полный e2e: ledger 11/11 и operations-persistence 5/5 passed. |
| A08 | Исправлено и проверено | Global/screen ValidationPipe сохраняют structured errors без target/value; filter обходит children и экранирует JSON pointers. Удалён descriptorErrors; OpenAPI errors.items согласован с object shape. | Nested Money HTTP red errors:[] → bank22 / screen29 passed; escaped pointer и отсутствие дублей/secret проверены. Unit7 и scoped lint passed. Metadata red items:string → финальный OpenAPI parity 3 passed. На macOS auth suite native SIGABRT; итоговый Linux auth 12/12 passed, включая credential field errors без input leak. |
| A09 | Исправлено и проверено | Planner импортирует существующий чистый operations/domain/operations-sync-plan как явно документированный публичный вход; service barrel больше не экспортирует runtime interval rules. | Planner и CandleService tests выполняются без Prisma mock; targeted пакет 7 suites / 49 tests passed. DI подтверждается configured HTTP и итоговым build. |
| A10 | Исправлено и проверено | Удалены шесть legacy methods, context types, TbankClient constructor dependency и accessibleTbankAccount без живых callers; legacy-only spec удалён. | Поиск src/test не находит legacy symbols; 5 unit suites / 11 tests, оба typecheck, scoped lint/build; connection HTTP/SQLite4 passed: missing/invalid/unsupported/cross-user, replacement/deletion, revision/fencing. Token error/crypto assertions сохранены. MacOS metadata-persistence15 passed, operations-sync/catalog-discovery native SIGABRT. Итоговый Linux e2e: metadata15, operations-sync17 и catalog-discovery10 passed. |
| C01 | Исправлено и проверено | PortfolioService использует common decimalFromString; локальный parser удалён. Входы из money/formatDecimal уже canonical; текущая ошибка сумм не заявляется. | Exact-money portfolio tests в targeted49 passed; итоговые types/verify ниже. |
| C02 | Исправлено и проверено | TbankSyncAttempt/TbankLastPrice экспортируются type-only через tbank.api; семь production consumers используют API, TbankCoupon переиспользован. | Поиск прямых imports tbank-access.service/tbank-market.types в src/modules пуст; оба typecheck и scoped lint passed. |
| C03 | Исправлено и проверено | Удалён no-op normalizeEmail; CredentialsDto transform trim/lowercase сохранён. | Auth unit passed; реальные case/whitespace HTTP assertions сохранены. После macOS native SIGABRT полный Linux auth gate прошёл: 12/12. |
| C04 | Исправлено и проверено | bankSummary принимает только рабочий диапазон from/to; строковый month overload удалён, полезные unit cases переведены на диапазоны. | Targeted49 passed: month start/end, outside range, signed64/BigInt суммы и refunds. |
| C05 | Исправлено и проверено | Удалены optional без callers и stockPosition wrapper; spec перенесён к instrumentPosition, проверяет напрямую владельца. | Targeted49 passed: fractional/large quantity, weighted costs, mixed/unknown currencies, no position и offset long/short. |
| C06 | Исправлено и проверено | Удалён неиспользуемый внешний totalsByCurrency export; живой mapper и внутренний caller сохранены. | Поиск imports подтверждает единственный внутренний caller; exact-money targeted tests passed, итоговый typecheck ниже. |
| C07 | Исправлено и проверено | Удалён HTTP_CODE_METADATA wiring spec; реальные register/login/refresh status200 и logout/cookie assertions сохранены в app.e2e. | Проверены callers/оставшиеся HTTP assertions; полный Linux auth gate прошёл: 12/12. Предыдущий macOS native SIGABRT остаётся отдельным ограничением среды. |
### Gates первого прогона (macOS)
Все команды выполнены из backend на Node `v24.21.0` после `nvm use --silent`.
Проверки данных использовали штатный runner, fresh temporary SQLite и штатные
migrations; рабочая БД не использовалась. Для localhost/Prisma cache запуски
получили разрешение вне sandbox; native abort повторялся и там.
| Команда | Итог |
| --- | --- |
| `npm ci` | PASS; manifest и lockfile без изменений. |
| `npm run verify` | PASS: lint, app/test typecheck, 36 suites / 209 unit tests с coverage, build. |
| `npm run openapi:generate` | PASS; актуальный игнорируемый `openapi.json` сформирован. |
| `npm run test:openapi-contract` | PASS: 1 suite / 3 tests, включая object shape validation errors. |
| `npx prisma validate` | PASS. Schema и migrations не менялись. |
| `npm run test:durability -- /tmp/wealthledger-fix-baseline-current-runtime-fjftdF` | PASS. Baseline `0862d56`, до последней T-Bank migration, отдельно установлен и собран на текущем Node; результат `/var/folders/j_/h2ft23gd3qq35ps3v5bcftfm0000gn/T/wealthledger-bank-durability-SIeCq0`. |
| `LOG_LEVEL=error npm run test:e2e` | INCOMPLETE, exit 1: all-accounts 18/18 passed, затем app.e2e native SIGABRT до Jest summary. Остальные suites общий gate не выполнил. |
| `node test/sqlite.cjs tbank-candles-persistence.e2e-spec.ts` | PASS на финальном коде: 14/14 после одного native abort и повтора без edits. |
| `node test/sqlite.cjs tbank-metadata-persistence.e2e-spec.ts` | PASS на финальном коде: 15/15. |
| `node test/sqlite.cjs tbank-operations-sync.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `node test/sqlite.cjs tbank-operations-persistence.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `node test/sqlite.cjs tbank-catalog-discovery.e2e-spec.ts` | INCOMPLETE: native SIGABRT до summary. |
| `git diff --check` | PASS. |
Логи итоговых gates: `/tmp/wealthledger-fix-verify.log`,
`/tmp/wealthledger-fix-openapi-generate.log`, `/tmp/wealthledger-fix-openapi-contract.log`,
`/tmp/wealthledger-fix-prisma-validate.log`, `/tmp/wealthledger-fix-durability.log`,
`/tmp/wealthledger-fix-final-e2e.log`. Дополнительные SQLite логи:
`/tmp/wealthledger-fix-a05-latest-sqlite-retry.log`,
`/tmp/wealthledger-fix-final-metadata-persistence.log`,
`/tmp/wealthledger-final-operations-sync.log`,
`/tmp/wealthledger-final-operations-persistence.log`,
`/tmp/wealthledger-fix-final-catalog-discovery.log`.
Native failure: assertion `(env) != nullptr` в `node::RemoveEnvironmentCleanupHook`,
stack содержит destructor из `better_sqlite3.node`. Это конкретное ограничение
текущего native runtime, уже наблюдавшееся при исходном аудите; его первопричина
в рамках исправлений не установлена. Тесты не отключались, runner/dependencies
не изменялись. Эти macOS запуски остаются INCOMPLETE. Полный Linux прогон ниже
закрыл e2e gate и auth/operations/catalog acceptance; локальный native abort
на macOS при этом не исправлен.
### Повторная полная проверка (Linux, 2026-09-27)
**PASS: 21 e2e suites / 262 tests, exit 0**, ни одного skipped/disabled test.
Выполнена вся штатная `npm run test:e2e`: build и `node test/sqlite.cjs --all`.
Каждая suite получила отдельную temporary SQLite со всеми пятью migrations.
Закрыты прежде неполные auth12, operations-persistence5, operations-sync17,
catalog-discovery10; также прошли candles14, forecast/upcoming20, screen29,
OpenAPI3 и большой read-baseline с 300 000 операций / 1 000 000 свечей.
Перед повтором зафиксирован тот же HEAD и hashes всех tracked/untracked files;
до завершения тестов ни один исходник, тест, config или audit не менялся.
Поэтому предыдущие `verify` (209 unit tests), OpenAPI и durability сохраняют
актуальность. В этом повторе изменён только статус данного документа.
MacOS `LOG_LEVEL=error node test/sqlite.cjs app.e2e-spec.ts` снова завершился
native SIGABRT. Для Linux использован существующий Dockerfile target
`development`, Node `v24.21.0` (проверен против `.nvmrc`) и неизменённый lockfile:
SHA-256 `f986979b7922d684fead3a4d32532d11f1e469cec0e35669509343be41cbb2cd`.
Image `wealthledger-backend:audit-verification-20260927`,
ID `sha256:69264cf3dba23566d99fded450104bbe56f92755604e88232beb97825155ae73`.
Сборка выполнила `npm ci`; зависимости и runner не подменялись. Test/scripts
подключались read-only, внешняя сеть отключена, host ports не публиковались;
одноразовый контейнер удалён после завершения, рабочая БД не подключалась.
Команды воспроизведения из backend (на host предварительно `source ~/.nvm/nvm.sh`
и `nvm use --silent`; внутри image закреплён тот же Node):
```bash
docker build --target development --tag wealthledger-backend:audit-verification-20260927 .
docker run --rm --network none \
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/test,target=/app/test,readonly \
--mount type=bind,source=/Users/ksv741/Project/vibe/WealthLedger/backend/scripts,target=/app/scripts,readonly \
-e LOG_LEVEL=error wealthledger-backend:audit-verification-20260927 npm run test:e2e
```
Логи: `/tmp/wealthledger-recheck-auth.log` (macOS abort),
`/tmp/wealthledger-recheck-linux-build.log` (Linux image build),
`/tmp/wealthledger-recheck-linux-e2e.log` (полный PASS).
Read-only диагностика локализовала macOS crash в GC finalizer Statement/Database
вложенного `better-sqlite3 12.11.1`, используемого Prisma adapter 7.10.0.
Node 24.21 ObjectWrap destructor вызывает RemoveEnvironmentCleanupHook, который
падает на `env != nullptr`. Связь с Jest VM context и происхождением native
binary остаётся гипотезой, а не доказанной первопричиной. Ошибка application
lifecycle не обнаружена: close/disconnect ожидаются. Linux PASS доказывает
поведение backend в этом окружении, но не устранение native crash на macOS.
### Semantic / Boy Scout review и состояние дерева
Повторный routing по staged/unstaged/new files активировал Architecture,
HTTP/errors, Data, Testing, Security, Integrations и Observability. Проверены
owners/callers и связанная документация; независимый review охватил auth/logging,
candle coalescing/publication, forecast fallback и cleanup. После устранения
найденной при review stale metadata у старого forecast fallback оставшихся
semantic замечаний нет. Проверены точная денежная арифметика, ownership,
transactions, generation fencing, сохранение cache/pending promises и отсутствие
новых дублирующих helpers.
На момент завершения проверки, до запроса публикации, HEAD оставался
`516e43203eb3fee8e6a0c7018be2fee11f1b900c`, ветка
`codex/investment-contracts`; staged files отсутствуют. Все изменения локальные,
без commit/push/PR. Новые untracked files: исходный `BACKEND_AUDIT.md`,
`src/modules/investments/instruments/application/candle.service.spec.ts` и
`src/modules/investments/instruments/application/instrument-position.spec.ts`.
Остальные изменения — tracked source/tests и три связанных convention-файла.
Новые зависимости, scripts, migrations и разделение production-файлов не добавлены.
Исходный текст до раздела «Статус исправлений» побайтово совпадает со снимком
`/tmp/wealthledger-backend-audit-before-fixes.md`.
### Подготовка публикации
После успешной полной проверки пользователь разрешил commit, PR и merge.
Ветка публикации — `codex/backend-audit-fixes` от актуального `origin/develop`
`5d3835e`; его tree совпадает с исходным HEAD проверки, поэтому исходники и
результаты gates не изменились. Pre-commit review не обнаружил посторонних
файлов, секретов или незакрытых semantic findings. PR направляется в `develop`;
merge выполняется после обязательных `verify`, `security`, `commits`.
+93
View File
@@ -1,5 +1,98 @@
# Changelog # Changelog
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
### Bug Fixes
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
### Performance
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
### Refactoring
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
### CI
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
### Tests
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
### Styles
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
### Maintenance
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+68 -10
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint, Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
выполните обе команды. Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`, Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6 включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -74,17 +77,38 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
### CI и обновления зависимостей ### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client, Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push, значениями, блокирует любой runtime advisory через
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические `npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter. совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются. Новые коммиты этими исключениями не покрываются.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Backend verify / commits (pull_request)`,
`Backend verify / security (pull_request)` и
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
Renovate группирует minor/patch updates, а major требует согласования в Renovate группирует minor/patch updates, а major требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install, фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
@@ -105,6 +129,38 @@ Required checks настраиваются отдельно в Gitea: налич
runtime-валидацию. В обычном коде нужны инициализация или сужение типа 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 ## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru` Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
@@ -239,9 +295,11 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+10 -2
View File
@@ -1,4 +1,12 @@
module.exports = { module.exports = {
extends: ["@commitlint/config-conventional"], extends: ['@commitlint/config-conventional'],
rules: { "type-enum": [2, "always", ["feat", "fix", "perf", "docs", "spec", "refactor", "build", "ci", "test", "style", "chore", "revert"]] }, defaultIgnores: false,
ignores: [
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
],
rules: {
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
'references-empty': [2, 'never'],
},
}; };
+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 // @ts-check
import nest from '@darraghor/eslint-plugin-nestjs-typed';
import eslint from '@eslint/js'; 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 globals from 'globals';
import tseslint from 'typescript-eslint'; 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/'], files: ['**/*.{ts,js,cjs,mjs}'],
}, extends: [
eslint.configs.recommended, eslint.configs.recommended,
...tseslint.configs.recommendedTypeChecked, node.configs['flat/recommended'],
eslintPluginPrettierRecommended, importX.flatConfigs.recommended,
{ sonarjs.configs.recommended,
languageOptions: { regexp.configs['flat/recommended'],
globals: { security.configs.recommended,
...globals.node, perfectionist.configs['recommended-natural'],
...globals.jest, stylistic.configs.customize({ indent: 2, quotes: 'single', semi: true, jsx: false }),
}, ],
sourceType: 'commonjs', languageOptions: { globals: globals.node },
parserOptions: { linterOptions: { reportUnusedDisableDirectives: 'error' },
projectService: true, settings: {
tsconfigRootDir: import.meta.dirname, '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: { 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', 'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': [ '@typescript-eslint/no-magic-numbers': [
'error', '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, ignoreArrayIndexes: true,
ignoreDefaultValues: true, ignoreDefaultValues: true,
ignoreEnums: true, ignoreEnums: true,
@@ -63,24 +98,117 @@ export default tseslint.config(
enforceConst: true, enforceConst: true,
}, },
], ],
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-non-null-assertion': 'error', '@typescript-eslint/no-non-null-assertion': 'error',
'no-restricted-syntax': [ 'no-restricted-syntax': ['error', {
'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.',
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' }],
}, },
}, },
{ {
files: ['**/*.spec.ts', 'test/**/*.ts'], files: ['src/**/*.ts'],
rules: { '@typescript-eslint/no-magic-numbers': 'off' }, 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", "name": "wealth-ledger-backend",
"version": "1.0.0", "version": "2.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -11,14 +11,14 @@
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs", "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": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "nest start --debug --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint \"{src,apps,libs,test}/**/*.ts\" --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
"lint:fix": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "lint:fix": "eslint . --fix --max-warnings=0",
"typecheck:app": "tsc --project tsconfig.typecheck.app.json", "typecheck:app": "tsc --project tsconfig.typecheck.app.json",
"typecheck:test": "tsc --project tsconfig.typecheck.test.json", "typecheck:test": "tsc --project tsconfig.typecheck.test.json",
"test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js",
@@ -26,17 +26,19 @@
"test:cov": "npm test -- --coverage", "test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run build && node test/sqlite.cjs --all", "test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs", "test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", "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:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint" "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.4", "@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1", "@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0", "@nestjs/config": "12.0.0",
@@ -53,6 +55,7 @@
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"dotenv": "17.4.2",
"lru-cache": "11.5.2", "lru-cache": "11.5.2",
"nestjs-pino": "5.1.0", "nestjs-pino": "5.1.0",
"passport": "0.7.0", "passport": "0.7.0",
@@ -65,12 +68,13 @@
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.2", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "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", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.0", "@nestjs/cli": "12.0.0",
"@nestjs/schematics": "12.0.1", "@nestjs/schematics": "12.0.1",
"@nestjs/testing": "12.0.1", "@nestjs/testing": "12.0.1",
"@release-it/conventional-changelog": "12.0.0", "@release-it/conventional-changelog": "12.0.0",
"@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
@@ -79,12 +83,17 @@
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.0", "conventional-changelog-conventionalcommits": "10.4.0",
"eslint": "10.10.0", "eslint": "10.11.0",
"eslint-config-prettier": "10.1.8", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-prettier": "5.5.6", "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", "globals": "17.12.0",
"jest": "30.5.1", "jest": "30.5.1",
"prettier": "3.9.6",
"release-it": "21.0.2", "release-it": "21.0.2",
"source-map-support": "0.5.21", "source-map-support": "0.5.21",
"supertest": "7.2.2", "supertest": "7.2.2",
@@ -93,15 +102,20 @@
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.70.0" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8",
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.3.0" "multer": "2.4.0"
}, },
"@prisma/config@7.10.0": { "@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2" "deepmerge-ts": "8.0.2"
}, },
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
},
"prisma": { "prisma": {
"mysql2": "3.24.4" "mysql2": "3.24.4"
} }
@@ -0,0 +1,27 @@
-- CreateTable
CREATE TABLE "TbankPortfolioValueHistory" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"deletedAt" DATETIME,
"updatedAt" DATETIME NOT NULL,
PRIMARY KEY ("userId", "accountId"),
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"currency" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- CreateIndex
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
+29
View File
@@ -17,6 +17,7 @@ model User {
bankCardOperations BankCardOperation[] bankCardOperations BankCardOperation[]
sessions Session[] sessions Session[]
tbankConnection TbankConnection? tbankConnection TbankConnection?
tbankValueHistory TbankPortfolioValueHistory[]
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
@@ -69,6 +70,34 @@ model TbankPortfolio {
@@id([userId, revision, accountId]) @@id([userId, revision, accountId])
} }
// Account valuation history survives connection replacement and stays hidden
// until the current connection confirms the account again.
model TbankPortfolioValueHistory {
userId String
accountId String
deletedAt DateTime?
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
points TbankPortfolioValuePoint[]
@@id([userId, accountId])
}
// One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint {
userId String
accountId String
attemptId String
currency String
value String
capturedAt DateTime
createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt])
}
model TbankOperation { model TbankOperation {
userId String userId String
revision String revision String
+10 -9
View File
@@ -1,10 +1,11 @@
const assert = require("node:assert/strict"); const assert = require('node:assert/strict');
const { execFileSync } = require("node:child_process"); const { execFileSync } = require('node:child_process');
const { version } = require("../package.json");
const git = (...args) => execFileSync("git", args, { encoding: "utf8" }).trim();
assert.equal(git("status", "--porcelain"), "", "Commit all changes before preparing a release"); const { version } = require('../package.json');
const tags = git("tag", "--merged", "HEAD", "--list", "v[0-9]*", "--sort=-version:refname") // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
.split("\n").filter(tag => /^v\d+\.\d+\.\d+$/.test(tag)); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
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 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 { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js'); const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.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 { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -13,6 +11,7 @@ import { InstrumentsModule } from './modules/investments/instruments/instruments
import { OperationsModule } from './modules/investments/operations/operations.module'; import { OperationsModule } from './modules/investments/operations/operations.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.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 { UpcomingEventsModule } from './modules/investments/upcoming-events/upcoming-events.module';
import { PrismaModule } from './prisma/prisma.module'; import { PrismaModule } from './prisma/prisma.module';
@@ -22,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true, isGlobal: true,
validate: validateEnv, validate: validateEnv,
}), }),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, AuthModule,
+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 { 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'); throw new Error('Invalid decimal string');
}
const negative = value.startsWith('-'); const negative = value.startsWith('-');
const [whole, fraction = ''] = (negative ? value.slice(1) : value).split('.'); const [whole, fraction = ''] = (negative ? value.slice(SIGN_LENGTH) : value).split('.');
return { return {
value: (negative ? -1n : 1n) * BigInt(`${whole}${fraction}`), value: (negative ? NEGATIVE_SIGN : POSITIVE_SIGN) * BigInt(`${whole}${fraction}`),
scale: fraction.length, scale: fraction.length,
}; };
} }
@@ -15,8 +33,8 @@ export function add(left: Decimal, right: Decimal): Decimal {
const scale = Math.max(left.scale, right.scale); const scale = Math.max(left.scale, right.scale);
return { return {
value: value:
left.value * 10n ** BigInt(scale - left.scale) + left.value * DECIMAL_RADIX ** BigInt(scale - left.scale) +
right.value * 10n ** BigInt(scale - right.scale), right.value * DECIMAL_RADIX ** BigInt(scale - right.scale),
scale, scale,
}; };
} }
@@ -30,33 +48,37 @@ export function multiply(left: Decimal, right: Decimal): Decimal {
} }
export function isZero(value: Decimal): boolean { export function isZero(value: Decimal): boolean {
return value.value === 0n; return value.value === ZERO_UNITS;
} }
export function compare(left: Decimal, right: Decimal): number { export function compare(left: Decimal, right: Decimal): number {
const difference = subtract(left, right).value; 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 { export function formatDecimal({ value, scale }: Decimal): string {
if (value === 0n) return '0'; if (value === ZERO_UNITS) return '0';
const sign = value < 0n ? '-' : ''; const sign = value < ZERO_UNITS ? '-' : '';
if (scale === 0) return `${sign}${value < 0n ? -value : value}`; if (scale === INTEGER_SCALE) return `${sign}${value < ZERO_UNITS ? -value : value}`;
const digits = (value < 0n ? -value : value) const digits = (value < ZERO_UNITS ? -value : value)
.toString() .toString()
.padStart(scale + 1, '0'); .padStart(scale + MIN_WHOLE_DIGITS, '0');
const whole = digits.slice(0, -scale) || '0'; const whole = digits.slice(DIGITS_START, -scale) || '0';
const fraction = digits.slice(-scale).replace(/0+$/, ''); const rawFraction = digits.slice(-scale);
return `${sign}${whole}${fraction ? `.${fraction}` : ''}`; 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. // Round half away from zero; callers choose presentation precision explicitly.
export function divide(left: Decimal, right: Decimal, scale: number): Decimal { export function divide(left: Decimal, right: Decimal, scale: number): Decimal {
if (right.value === 0n) throw new Error('Division by zero'); if (right.value === ZERO_UNITS) throw new Error('Division by zero');
const numerator = left.value * 10n ** BigInt(right.scale + scale); const numerator = left.value * DECIMAL_RADIX ** BigInt(right.scale + scale);
const denominator = right.value * 10n ** BigInt(left.scale); const denominator = right.value * DECIMAL_RADIX ** BigInt(left.scale);
const sign = numerator < 0n !== denominator < 0n ? -1n : 1n; const sign = (numerator < ZERO_UNITS) !== (denominator < ZERO_UNITS) ? NEGATIVE_SIGN : POSITIVE_SIGN;
const n = numerator < 0n ? -numerator : numerator; const n = numerator < ZERO_UNITS ? -numerator : numerator;
const d = denominator < 0n ? -denominator : denominator; const d = denominator < ZERO_UNITS ? -denominator : denominator;
return { value: sign * ((n + d / 2n) / d), scale }; return { value: sign * ((n + d / ROUNDING_DIVISOR) / d), scale };
} }
@@ -1,23 +1,32 @@
import { HttpStatus, applyDecorators } from '@nestjs/common'; import { applyDecorators, HttpStatus } from '@nestjs/common';
import { import {
ApiExtraModels, ApiExtraModels,
ApiProperty, ApiProperty,
ApiPropertyOptional,
ApiResponse, ApiResponse,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto { export class ProblemDetailsDto {
@ApiProperty({ example: 401 }) @ApiProperty({ example: 401 })
declare status: number; declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' }) @ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string; declare code: string;
@ApiProperty({ // eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
required: false, @ApiPropertyOptional({
type: 'array',
items: {
type: 'object',
required: ['pointer', 'detail'],
properties: { pointer: { type: 'string' }, detail: { type: 'string' } },
},
example: [{ pointer: '#/email', detail: 'must be a valid email address' }], 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) { function problemResponse(status: number, description: string) {
@@ -32,7 +41,7 @@ function problemResponse(status: number, description: string) {
}); });
} }
export function ApiProblemResponses(): MethodDecorator & ClassDecorator { export function ApiProblemResponses(): ClassDecorator & MethodDecorator {
return applyDecorators( return applyDecorators(
ApiExtraModels(ProblemDetailsDto), ApiExtraModels(ProblemDetailsDto),
problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'), problemResponse(HttpStatus.BAD_REQUEST, 'Invalid request'),
+3 -1
View File
@@ -1,4 +1,5 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { CredentialsDto } from '../modules/auth/presentation/auth.dto'; import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
@@ -6,7 +7,8 @@ import {
} from '../modules/banking/bank-cards/presentation/bank-card.dto'; } from '../modules/banking/bank-cards/presentation/bank-card.dto';
describe('DTO validation', () => { 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 () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: CredentialsDto }; 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 { ArgumentsHost } from '@nestjs/common';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
@@ -14,7 +15,10 @@ describe('HttpExceptionFilter', () => {
}), }),
} as unknown as ArgumentsHost; } as unknown as ArgumentsHost;
filter.catch(new Error('database password: secret'), host); filter.catch(
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
host,
);
expect(status).toHaveBeenCalledWith(500); expect(status).toHaveBeenCalledWith(500);
expect(type).toHaveBeenCalledWith('application/problem+json'); expect(type).toHaveBeenCalledWith('application/problem+json');
@@ -23,7 +27,6 @@ describe('HttpExceptionFilter', () => {
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
}); });
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
expect(typeof locals.errorStack).toBe('string'); expect(locals.errorStack).toBeUndefined();
expect(locals.errorStack).not.toContain('database password: secret');
}); });
}); });
+27 -24
View File
@@ -1,3 +1,5 @@
import type { Response } from 'express';
import { import {
ArgumentsHost, ArgumentsHost,
Catch, Catch,
@@ -6,7 +8,8 @@ import {
HttpStatus, HttpStatus,
ValidationError, ValidationError,
} from '@nestjs/common'; } from '@nestjs/common';
import type { Response } from 'express';
import { HttpErrorCode } from '../error-code';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
@@ -24,17 +27,10 @@ export class HttpExceptionFilter implements ExceptionFilter {
(payload as { message?: unknown })?.message, (payload as { message?: unknown })?.message,
); );
const code = isValidation const code = isValidation
? 'VALIDATION_ERROR' ? HttpErrorCode.ValidationError
: this.codeFor(status, payload); : this.codeFor(status, payload);
(response.locals ??= {}).publicErrorCode = code; response.locals ??= {};
if ( response.locals.publicErrorCode = code;
status >= Number(HttpStatus.INTERNAL_SERVER_ERROR) &&
exception instanceof Error
)
response.locals.errorStack = exception.stack
?.split('\n')
.slice(1)
.join('\n');
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
@@ -57,24 +53,31 @@ export class HttpExceptionFilter implements ExceptionFilter {
if (typeof code === 'string') return code; if (typeof code === 'string') return code;
} }
return ( return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{ {
400: 'BAD_REQUEST', 400: HttpErrorCode.BadRequest,
401: 'UNAUTHORIZED', 401: HttpErrorCode.Unauthorized,
404: 'NOT_FOUND', 404: HttpErrorCode.NotFound,
415: 'UNSUPPORTED_MEDIA_TYPE', 415: HttpErrorCode.UnsupportedMediaType,
500: 'INTERNAL_SERVER_ERROR', 500: HttpErrorCode.InternalServerError,
}[status] ?? 'INTERNAL_SERVER_ERROR' }[status] ?? HttpErrorCode.InternalServerError
); );
} }
private validationErrors( private validationErrors(
errors: ValidationError[], errors: ValidationError[],
): { pointer: string; detail: string }[] { prefix = '#',
return errors.flatMap((error) => ): { detail: string; pointer: string }[] {
Object.values(error.constraints ?? {}).map((detail) => ({ return errors.flatMap((error) => {
pointer: `#/${error.property}`, if (!error || typeof error.property !== 'string') return [];
detail, const pointer = `${prefix}/${error.property.replaceAll('~', '~0').replaceAll('/', '~1')}`;
})), return [
); ...Object.values(error.constraints ?? {}).map((detail) => ({
pointer,
detail,
})),
...this.validationErrors(error.children ?? [], pointer),
];
});
} }
} }
+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 { Global, Module } from '@nestjs/common';
import { RequestContextService } from './request-context.service'; import { ConfigService } from '@nestjs/config';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { Environment } from '../../config/env.validation';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global() @Global()
@Module({ @Module({
providers: [ imports: [LoggerModule.forRootAsync<Request, Response>({
RequestContextService, inject: [ConfigService],
RequestLoggingMiddleware, useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
StructuredLogger, })],
], providers: [StructuredLogger],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger], exports: [StructuredLogger],
}) })
export class LoggingModule {} export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
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,68 +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 }
: {}),
...(response.locals.errorStack
? { stack: response.locals.errorStack as string }
: {}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => { describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = { const pino = {
debug: jest.fn(), debug: jest.fn(),
info: jest.fn(), info: jest.fn(),
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => { it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' }); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.info('event', { logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
requestId: 'request', asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
}); });
it('delegates event levels to Pino', () => { it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.event('debug', 'debug event'); logger.event('debug', 'debug event');
logger.event('warn', 'warn event'); logger.event('warn', 'warn event');
+19 -22
View File
@@ -1,36 +1,32 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'info' | 'warn' | 'error'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, string | number | boolean | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'service', 'asOf',
'environment',
'requestId',
'clientRequestId',
'userId',
'brokerageAccountId',
'method',
'route',
'status',
'durationMs',
'code',
'stack',
'operation',
'grpcCode',
'attempts', 'attempts',
'brokerageAccountId',
'clientRequestId',
'code',
'count', 'count',
'durationMs',
'environment',
'found', 'found',
'grpcCode',
'method',
'operation',
'requestId',
'route',
'service',
'status',
'userId',
]); ]);
@Injectable() @Injectable()
export class StructuredLogger implements OnApplicationShutdown { export class StructuredLogger implements OnApplicationShutdown {
constructor( constructor(private readonly pino: PinoLogger) {}
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
this.write('info', message, context); this.write('info', message, context);
@@ -49,8 +45,9 @@ export class StructuredLogger implements OnApplicationShutdown {
message: string, message: string,
context: LogContext = {}, context: LogContext = {},
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext(context),
message, message,
); );
} }
+1
View File
@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import type { Money } from './money.types'; import type { Money } from './money.types';
export class MoneyDto implements Money { 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_SECOND = 1_000;
export const MILLISECONDS_PER_DAY = 86_400_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_PORT = 3000;
const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5; const DEFAULT_TBANK_RATE_LIMIT_PER_SECOND = 5;
const MAX_PORT = 65_535; const MAX_PORT = 65_535;
@@ -7,12 +10,12 @@ export interface Environment {
DATABASE_URL: string; DATABASE_URL: string;
FRONTEND_ORIGIN: string; FRONTEND_ORIGIN: string;
JWT_SECRET: string; JWT_SECRET: string;
LOG_LEVEL: 'debug' | 'error' | 'info' | 'warn';
PORT: number; PORT: number;
TOKEN_ENCRYPTION_KEY: string;
TBANK_CA_CERT_PATH?: string; TBANK_CA_CERT_PATH?: string;
TBANK_GRPC_ENDPOINT: string; TBANK_GRPC_ENDPOINT: string;
TBANK_RATE_LIMIT_PER_SECOND: number; TBANK_RATE_LIMIT_PER_SECOND: number;
LOG_LEVEL: 'debug' | 'info' | 'warn' | 'error'; TOKEN_ENCRYPTION_KEY: string;
} }
export function validateEnv(config: Record<string, unknown>): Environment { export function validateEnv(config: Record<string, unknown>): Environment {
@@ -56,7 +59,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
const parsedPort = Number(port); const parsedPort = Number(port);
if ( if (
!Number.isInteger(parsedPort) || !Number.isInteger(parsedPort) ||
parsedPort < 1 || parsedPort < MIN_PORT ||
parsedPort > MAX_PORT parsedPort > MAX_PORT
) { ) {
throw new Error('PORT must be a valid port number'); throw new Error('PORT must be a valid port number');
@@ -71,7 +74,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
if ( if (
typeof tokenEncryptionKey !== 'string' || 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 Buffer.from(tokenEncryptionKey, 'base64').length !== TOKEN_KEY_BYTES
) { ) {
throw new Error( throw new Error(
@@ -84,7 +87,7 @@ export function validateEnv(config: Record<string, unknown>): Environment {
} }
const parsedTbankRateLimit = Number(tbankRateLimit); 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'); 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'); 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'); throw new Error('LOG_LEVEL must be debug, info, warn, or error');
} }
+179 -11
View File
@@ -1,5 +1,11 @@
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
const emptyEvents = {
coupons: { columns: ['coupondate', 'value', 'faceunit'], data: [] },
offers: { columns: ['offerdate'], data: [] },
description: { columns: ['name', 'value'], data: [] },
};
describe('MoexClient', () => { describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks()); afterEach(() => jest.restoreAllMocks());
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
@@ -92,17 +98,179 @@ describe('MoexClient', () => {
expect(result?.value ?? null).toBe(value === null ? null : '0'); expect(result?.value ?? null).toBe(value === null ? null : '0');
}, },
); );
it('treats a successful response without the requested block as no events', async () => { it('accepts confirmed empty blocks', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockResolvedValue({ jest
ok: true, .spyOn(global, 'fetch')
json: () => .mockImplementation(() => Promise.resolve(Response.json(emptyEvents)));
Promise.resolve({
description: { columns: ['name', 'value'], data: [] },
boards: { columns: ['secid'], data: [] },
}),
} as Response);
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]); await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([]);
fetchMock.mockRestore();
}); });
it('preserves not-found semantics independently of malformed HTTP 200', async () => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(new Response(null, { status: 404 })),
);
const client = new MoexClient();
await expect(client.bondEvents('MISSING')).resolves.toEqual([]);
await expect(
client.bondYield('MISSING', 'ISIN', 'TQOB'),
).resolves.toBeNull();
});
it.each([
['missing block', { coupons: undefined }],
['missing columns', { coupons: { data: [] } }],
['invalid column', { coupons: { columns: [null], data: [] } }],
['invalid row', { coupons: { columns: ['coupondate'], data: [null] } }],
[
'short row',
{ coupons: { columns: ['coupondate', 'value'], data: [['2099-02-01']] } },
],
[
'long row',
{ coupons: { columns: ['coupondate'], data: [['2099-02-01', 2]] } },
],
[
'empty cursor',
{
'coupons.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [] },
},
],
[
'missing cursor field',
{ 'coupons.cursor': { columns: ['INDEX', 'TOTAL'], data: [[0, 1]] } },
],
[
'null cursor index',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[null, 0, 1]],
},
},
],
[
'boolean cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, false, 1]],
},
},
],
[
'empty cursor total',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, '', 1]],
},
},
],
[
'short page',
{
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 1]],
},
},
],
])(
'rejects %s without returning a partial calendar',
async (_name, invalid) => {
jest
.spyOn(global, 'fetch')
.mockImplementation(() =>
Promise.resolve(Response.json({ ...emptyEvents, ...invalid })),
);
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
},
);
it.each([0, -1])(
'rejects nonpositive page size %s before repeating a fetch',
async (pageSize) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
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) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
...emptyEvents,
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, pageSize]],
},
}),
);
});
await expect(new MoexClient().bondEvents('BOND')).rejects.toMatchObject({
response: { code: 'MOEX_UNAVAILABLE' },
});
expect(starts).toEqual(['0']);
},
);
it.each([false, true])(
'reads all cursor pages and rejects a repeated index: %s',
async (repeatIndex) => {
const starts: string[] = [];
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : input);
const start = Number(url.searchParams.get('start'));
if (url.searchParams.get('iss.only')?.startsWith('coupons,')) {
starts.push(String(start));
if (starts.length > 2) {
return Promise.reject(new Error('Repeated page'));
}
}
return Promise.resolve(
Response.json({
...emptyEvents,
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [[`2099-02-0${start + 1}`, '2.000000001', 'SUR']],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[repeatIndex ? 0 : start, 2, 1]],
},
}),
);
});
const result = new MoexClient().bondEvents('BOND');
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 {
// 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',
date: '2099-02-01',
value: '2.000000001',
currency: 'RUB',
},
{
type: 'coupon',
date: '2099-02-02',
value: '2.000000001',
currency: 'RUB',
},
]);
}
expect(starts).toEqual(['0', '1']);
},
);
}); });
+57 -34
View File
@@ -1,5 +1,13 @@
import { constants as httpStatus } from 'node:http2';
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; 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; const FALLBACK_PAGE_SIZE = 100;
@@ -7,18 +15,18 @@ const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
export type MoexBondEvent = export type MoexBondEvent =
| { type: 'coupon'; date: string; value: string; currency: string } | { currency: string; date: string; type: 'coupon'; value: string } |
| { type: 'maturity'; date: string; value: string; currency: string } { currency: string; date: string; type: 'maturity'; value: string } |
| { type: 'offer'; date: string }; { date: string; type: 'offer' };
type IssBlock = { columns?: unknown; data?: unknown }; type IssBlock = { columns?: unknown; data?: unknown };
type IssResponse = Record<string, IssBlock | undefined>; type IssResponse = Record<string, IssBlock | undefined>;
export type MoexBondYield = { export type MoexBondYield = {
value: string;
pricePercent: string;
date: string; date: string;
dateType: string | null; dateType: null | string;
pricePercent: string;
tradeAt: string; tradeAt: string;
value: string;
}; };
@Injectable() @Injectable()
@@ -52,14 +60,15 @@ export class MoexClient {
if ( if (
value === null || value === null ||
pricePercent === null || pricePercent === null ||
Number(pricePercent) <= 0 || Number(pricePercent) <= MIN_PRICE_PERCENT_EXCLUSIVE ||
!date || !date ||
!/^\d{4}-\d{2}-\d{2}$/.test(date) || !/^\d{4}-\d{2}-\d{2}$/.test(date) ||
!Number.isFinite(Date.parse(date)) || !Number.isFinite(Date.parse(date)) ||
!tradeAt || !tradeAt ||
!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt) !/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(tradeAt)
) ) {
return null; return null;
}
const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`; const tradeIso = `${tradeAt.replace(' ', 'T')}+03:00`;
if (!Number.isFinite(Date.parse(tradeIso))) return null; if (!Number.isFinite(Date.parse(tradeIso))) return null;
return { return {
@@ -70,6 +79,7 @@ export class MoexClient {
tradeAt: new Date(tradeIso).toISOString(), tradeAt: new Date(tradeIso).toISOString(),
}; };
} }
async bondEvents(secid: string): Promise<MoexBondEvent[]> { async bondEvents(secid: string): Promise<MoexBondEvent[]> {
const path = `securities/${encodeURIComponent(secid)}/bondization.json`; const path = `securities/${encodeURIComponent(secid)}/bondization.json`;
const [coupons, offers, description] = await Promise.all([ const [coupons, offers, description] = await Promise.all([
@@ -86,8 +96,9 @@ export class MoexClient {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
const currency = currencyText(row.faceunit); const currency = currencyText(row.faceunit);
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency }); events.push({ type: 'coupon', date, value, currency });
}
} }
for (const row of offers) { for (const row of offers) {
const date = text(row.offerdate); const date = text(row.offerdate);
@@ -99,8 +110,9 @@ export class MoexClient {
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
const currency = currencyText(values.get('FACEUNIT')); const currency = currencyText(values.get('FACEUNIT'));
if (date && value && currency) if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency }); events.push({ type: 'maturity', date, value, currency });
}
return events; return events;
} }
@@ -110,35 +122,41 @@ export class MoexClient {
paginated = true, paginated = true,
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const rows: Record<string, unknown>[] = []; const rows: Record<string, unknown>[] = [];
let start = 0; let start = FIRST_PAGE_OFFSET;
for (;;) { for (;;) {
const query = new URLSearchParams({ const query = new URLSearchParams({
'iss.meta': 'off', 'iss.meta': 'off',
'iss.only': `${block},${block}.cursor`, 'iss.only': `${block},${block}.cursor`,
start: String(start), 'start': String(start),
}); });
const response = await this.request(`${baseUrl}/${path}?${query}`); const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) return rows; 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]); const current = parseRows(response[block]);
rows.push(...current); rows.push(...current);
if (!paginated) return rows; if (!paginated) return rows;
const cursorBlock = response[`${block}.cursor`]; const cursorBlock = response[`${block}.cursor`];
if (!cursorBlock) { if (cursorBlock === undefined) {
if (current.length < FALLBACK_PAGE_SIZE) return rows; if (current.length < FALLBACK_PAGE_SIZE) return rows;
start += FALLBACK_PAGE_SIZE; start += FALLBACK_PAGE_SIZE;
continue; continue;
} }
const cursor = parseRows(cursorBlock)[0]; const cursors = parseRows(cursorBlock);
const cursor = cursors[0];
const index = integer(cursor?.index); const index = integer(cursor?.index);
const total = integer(cursor?.total); const total = integer(cursor?.total);
const pageSize = integer(cursor?.pagesize); const pageSize = integer(cursor?.pagesize);
if ( if (
index === null || cursors.length !== EXPECTED_CURSOR_ROWS ||
index !== start ||
total === null || total === null ||
pageSize === null || pageSize === null ||
index + pageSize >= total pageSize === EMPTY_PAGE_SIZE ||
) current.length !== Math.min(pageSize, total - start)
return rows; ) {
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
if (index + pageSize >= total) return rows;
start = index + pageSize; start = index + pageSize;
} }
} }
@@ -151,46 +169,51 @@ export class MoexClient {
if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null; if (response.status === httpStatus.HTTP_STATUS_NOT_FOUND) return null;
if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`); if (!response.ok) throw new Error(`MOEX HTTP ${response.status}`);
const body: unknown = await response.json(); const body: unknown = await response.json();
if (!body || typeof body !== 'object') if (!body || typeof body !== 'object') {
throw new Error('MOEX malformed response'); throw new Error('MOEX malformed response');
}
return body as IssResponse; return body as IssResponse;
} catch { } catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
} }
} }
} }
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] { function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!block) return []; if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
if (!Array.isArray(block.columns) || !Array.isArray(block.data)) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); }
const columns = block.columns; const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => { return block.data.map((values) => {
if (!Array.isArray(values)) if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return Object.fromEntries( return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
columns.map((column, index) => [column.toLowerCase(), values[index]]), 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; 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); 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; 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(); const result = text(value)?.toUpperCase();
return result === 'SUR' ? 'RUB' : (result ?? null); return result === 'SUR' ? 'RUB' : (result ?? null);
} }
function integer(value: unknown): number | null { function integer(value: unknown): null | number {
const result = Number(value); 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 { Module } from '@nestjs/common';
import { MoexClient } from './moex.client'; import { MoexClient } from './moex.client';
@Module({ @Module({
@@ -1,4 +1,5 @@
import { ServiceUnavailableException } from '@nestjs/common'; import { ServiceUnavailableException } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
const isin = 'RU000A10EW44'; const isin = 'RU000A10EW44';
const row = (value = 'AAA.ru', code = isin, href = '/ratings/issues/123/') => 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 () => { it('bounds outage retries then recovers without retaining a false missing rating', async () => {
const fetchMock = jest const fetchMock = jest
.spyOn(global, 'fetch') .spyOn(global, 'fetch')
+29 -14
View File
@@ -2,6 +2,13 @@ import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { load } from 'cheerio'; import { load } from 'cheerio';
import { LRUCache } from 'lru-cache'; 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 REGISTRY_CACHE_TTL_MS = 21_600_000;
const REQUEST_TIMEOUT_MS = 10_000; const REQUEST_TIMEOUT_MS = 10_000;
const COLUMN_COUNT = 6; const COLUMN_COUNT = 6;
@@ -11,11 +18,11 @@ const DATE_COLUMN = 5;
const registryUrl = 'https://ratings.ru/ratings/issues/'; const registryUrl = 'https://ratings.ru/ratings/issues/';
export type NcrRating = { export type NcrRating = {
agency: 'NCR'; agency: 'NCR';
value: string | null;
status: 'active' | 'withdrawn';
date: string; date: string;
sourceUrl: string;
observedAt: string; observedAt: string;
sourceUrl: string;
status: 'active' | 'withdrawn';
value: null | string;
}; };
@Injectable() @Injectable()
@@ -33,9 +40,10 @@ export class NcrRatingsClient {
if (!pending) { if (!pending) {
pending = this.registry().catch(() => { pending = this.registry().catch(() => {
// Bound outage traffic too; never present a failed download as no rating. // 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 }); this.cache.set('registry', pending, { ttl: 60_000 });
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
}); });
this.cache.set('registry', pending); this.cache.set('registry', pending);
} }
@@ -54,13 +62,16 @@ export class NcrRatingsClient {
.map((_, cell) => $(cell).text().trim()) .map((_, cell) => $(cell).text().trim())
.get(); .get();
if ( if (
table.length !== 1 || table.length !== EXPECTED_REGISTRY_TABLES ||
headings.length !== COLUMN_COUNT || headings.length !== COLUMN_COUNT ||
headings[2] !== 'Рейтинг' || headings[2] !== 'Рейтинг' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[ISIN_COLUMN] !== 'ISIN' || headings[ISIN_COLUMN] !== 'ISIN' ||
// eslint-disable-next-line security/detect-object-injection -- Column indexes are local numeric constants.
headings[DATE_COLUMN] !== 'Дата' headings[DATE_COLUMN] !== 'Дата'
) ) {
throw new Error('NCR registry changed'); throw new Error('NCR registry changed');
}
const observedAt = new Date().toISOString(); const observedAt = new Date().toISOString();
const result = new Map<string, NcrRating>(); const result = new Map<string, NcrRating>();
table.find('tbody tr').each((_, row) => { table.find('tbody tr').each((_, row) => {
@@ -69,27 +80,30 @@ export class NcrRatingsClient {
const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase(); const isin = cells.eq(ISIN_COLUMN).text().trim().toUpperCase();
// Unassigned issues cannot safely be matched to a held instrument. // Unassigned issues cannot safely be matched to a held instrument.
if (!/^[A-Z]{2}[A-Z0-9]{9}\d$/.test(isin)) return; 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() === 'рейтинг отозван'; const withdrawn = raw.toLowerCase() === 'рейтинг отозван';
if ( if (
!withdrawn && !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, raw,
) )
) ) {
throw new Error('NCR rating changed'); throw new Error('NCR rating changed');
}
const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec( const dateParts = /^(\d{2})\.(\d{2})\.(\d{4})$/.exec(
cells.eq(DATE_COLUMN).text().trim(), cells.eq(DATE_COLUMN).text().trim(),
); );
const href = cells.eq(ISIN_COLUMN).find('a').attr('href'); 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'); throw new Error('NCR identity missing');
}
const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`; const date = `${dateParts[3]}-${dateParts[2]}-${dateParts[1]}`;
if ( if (
!Number.isFinite(Date.parse(date)) || !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'); throw new Error('NCR date invalid');
}
const rating: NcrRating = { const rating: NcrRating = {
agency: 'NCR', agency: 'NCR',
value: withdrawn ? null : raw, value: withdrawn ? null : raw,
@@ -103,8 +117,9 @@ export class NcrRatingsClient {
previous && previous &&
previous.date === date && previous.date === date &&
(previous.value !== rating.value || previous.status !== rating.status) (previous.value !== rating.value || previous.status !== rating.status)
) ) {
throw new Error('NCR rating ambiguous'); throw new Error('NCR rating ambiguous');
}
if (!previous || previous.date < date) result.set(isin, rating); if (!previous || previous.date < date) result.set(isin, rating);
}); });
if (!result.size) throw new Error('NCR registry empty'); if (!result.size) throw new Error('NCR registry empty');
+1
View File
@@ -1,4 +1,5 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NcrRatingsClient } from './ncr-ratings.client'; import { NcrRatingsClient } from './ncr-ratings.client';
@Module({ @Module({
+15
View File
@@ -21,3 +21,18 @@ describe('decimal', () => {
).toBe('1'); ).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'; import { Decimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0;
const NANO = 1_000_000_000n; 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 { export function decimal(value: ProtoDecimal): Decimal {
return { 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, scale: 9,
}; };
} }
@@ -1,24 +1,31 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { import type {
Prisma,
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client'; } from '../../../generated/prisma/client';
import { PrismaService } from '../../prisma/prisma.service';
import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; 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 = { export type MetadataIdentity = {
instrumentUid?: string | null; figi?: null | string;
figi?: string | null; instrumentUid?: null | string;
}; };
export type MetadataLookup = { export type MetadataLookup = {
type: 'uid' | 'figi'; data: null | TbankInstrument;
id: string; id: string;
data: TbankInstrument | null; type: 'figi' | 'uid';
}; };
const provider = 'tbank'; const provider = 'tbank';
// Keep IN lists below the SQLite/Prisma parameter limit, including fixed predicates. // 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() }] ? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []), : []),
]); ]);
if (!identifiers.length) if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>(); return new Map<string, ReturnType<typeof selectMetadata>>();
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const lookups: MetadataLookupRow[] = []; const lookups: MetadataLookupRow[] = [];
for (const identifierType of ['uid', 'figi']) { for (const identifierType of ['uid', 'figi']) {
@@ -56,10 +64,10 @@ export class InstrumentMetadataRepository {
), ),
]; ];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < values.length; offset < values.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
lookups.push( lookups.push(
...(await tx.tbankInstrumentLookup.findMany({ ...(await tx.tbankInstrumentLookup.findMany({
where: { where: {
@@ -71,16 +79,17 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
} }
const uids = [ const uids = [
...new Set(lookups.flatMap((row) => row.matchedUids as string[])), ...new Set(lookups.flatMap((row) => row.matchedUids as string[])),
]; ];
const instruments: MetadataRow[] = []; const instruments: MetadataRow[] = [];
for ( for (
let offset = 0; let offset = FIRST_BATCH_OFFSET;
offset < uids.length; offset < uids.length;
offset += metadataReadBatchSize offset += metadataReadBatchSize
) ) {
instruments.push( instruments.push(
...(await tx.tbankInstrument.findMany({ ...(await tx.tbankInstrument.findMany({
where: { where: {
@@ -89,6 +98,7 @@ export class InstrumentMetadataRepository {
}, },
})), })),
); );
}
const byIdentifier = new Map( const byIdentifier = new Map(
lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]), lookups.map((row) => [`${row.identifierType}:${row.identifier}`, row]),
); );
@@ -103,7 +113,7 @@ export class InstrumentMetadataRepository {
} }
async reserve( async reserve(
attemptOwner: { userId: string; revision: string }, attemptOwner: { revision: string; userId: string },
identity: MetadataIdentity, identity: MetadataIdentity,
runId: string, runId: string,
active: ReadonlySet<string>, active: ReadonlySet<string>,
@@ -125,8 +135,9 @@ export class InstrumentMetadataRepository {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === runId && previous.processRunId === runId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId: runId, processRunId: runId,
@@ -183,12 +194,13 @@ export class InstrumentMetadataRepository {
['uid', uid], ['uid', uid],
['figi', figi], ['figi', figi],
] as const) { ] as const) {
if (id && uid && !aliases.has(`${type}:${id}`)) if (id && uid && !aliases.has(`${type}:${id}`)) {
aliases.set(`${type}:${id}`, { aliases.set(`${type}:${id}`, {
type, type,
id, id,
data: { ...result.data, uid }, data: { ...result.data, uid },
}); });
}
} }
} }
for (const result of aliases.values()) { for (const result of aliases.values()) {
@@ -196,8 +208,9 @@ export class InstrumentMetadataRepository {
const uid = const uid =
instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null); instrument?.uid?.trim() || (result.type === 'uid' ? result.id : null);
// FIGI is an alias, never a fabricated provider UID. // FIGI is an alias, never a fabricated provider UID.
if (instrument && !uid) if (instrument && !uid) {
throw new Error('Metadata response has no instrument UID'); throw new Error('Metadata response has no instrument UID');
}
if (instrument && uid) { if (instrument && uid) {
const key = { provider, uid }; const key = { provider, uid };
const previous = await tx.tbankInstrument.findUnique({ const previous = await tx.tbankInstrument.findUnique({
@@ -221,8 +234,8 @@ export class InstrumentMetadataRepository {
...data, ...data,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) increment: isDeepStrictEqual(previous?.metadata, payload)
? 0 ? UNCHANGED_REVISION_INCREMENT
: 1, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -244,8 +257,8 @@ export class InstrumentMetadataRepository {
fetchedAt: now, fetchedAt: now,
dataRevision: { dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? 0 ? UNCHANGED_REVISION_INCREMENT
: 1, : REVISION_INCREMENT,
}, },
}, },
}); });
@@ -263,18 +276,19 @@ export class InstrumentMetadataRepository {
}, },
data: { data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE', errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
}); });
if (success) if (success) {
await this.prisma.tbankSyncWork.updateMany({ await this.prisma.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
state: 'running', state: 'running',
connection: { invalidToken: true }, 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>, lookups: ReadonlyMap<string, MetadataLookupRow>,
instruments: ReadonlyMap<string, MetadataRow>, 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 lookup = lookups.get(`${type}:${id?.trim()}`);
const uid = (lookup?.matchedUids as string[] | undefined)?.[0]; const uid = (lookup?.matchedUids as string[] | undefined)?.[0];
const instrument = uid ? instruments.get(uid) : undefined; const instrument = uid ? instruments.get(uid) : undefined;
@@ -305,15 +319,12 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
(row) => (row) =>
`${row.lookup?.dataRevision ?? 0}:${row.instrument?.dataRevision ?? 0}`, `${row.lookup?.dataRevision ?? MISSING_DATA_REVISION}:${row.instrument?.dataRevision ?? MISSING_DATA_REVISION}`,
) )
.join('/'), .join('/'),
}; };
@@ -1,23 +1,25 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import {
InstrumentMetadataRepository,
type MetadataIdentity,
metadataKey,
type MetadataLookup,
} from './instrument-metadata.repository';
import { import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client'; import { TbankClient, type TbankInstrument } from './tbank.client';
import {
InstrumentMetadataRepository,
metadataKey,
type MetadataIdentity,
type MetadataLookup,
} from './instrument-metadata.repository';
export { metadataKey } from './instrument-metadata.repository'; export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository'; export type { MetadataIdentity } from './instrument-metadata.repository';
type Producer = { type Producer = {
identity: MetadataIdentity;
attempt: TbankSyncAttempt; attempt: TbankSyncAttempt;
identity: MetadataIdentity;
observers: TbankSyncAttempt[]; observers: TbankSyncAttempt[];
}; };
@Injectable() @Injectable()
@@ -49,8 +51,9 @@ export class InstrumentMetadataService {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
if (revision && connection.revision !== revision) return null; if (revision && connection.revision !== revision) return null;
const data = (await this.savedMany([identity])).get(metadataKey(identity)); const data = (await this.savedMany([identity])).get(metadataKey(identity));
if (!observe && !data?.fresh) if (!observe && !data?.fresh) {
await this.request(userId, connection.revision, identity); await this.request(userId, connection.revision, identity);
}
return { return {
...data, ...data,
work: await this.repository.work(userId, connection.revision, identity), work: await this.repository.work(userId, connection.revision, identity),
@@ -119,7 +122,7 @@ export class InstrumentMetadataService {
let useful = false; let useful = false;
let uidFailed = false; let uidFailed = false;
if (identity.instrumentUid) { if (identity.instrumentUid) {
let data: TbankInstrument | null | undefined; let data: null | TbankInstrument | undefined;
try { try {
data = await this.tbank.getInstrumentByUid( data = await this.tbank.getInstrumentByUid(
token, token,
@@ -129,30 +132,38 @@ export class InstrumentMetadataService {
if ( if (
!identity.figi || !identity.figi ||
(error instanceof Error && (error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN') isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) ) {
throw error; throw error;
}
uidFailed = true; uidFailed = true;
} }
if (data?.uid && data.uid !== identity.instrumentUid) if (data?.uid && data.uid !== identity.instrumentUid) {
throw new Error('Metadata UID mismatch'); throw new Error('Metadata UID mismatch');
if (data !== undefined) }
if (data !== undefined) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
useful = Boolean(data?.name?.trim() || data?.ticker?.trim()); useful = Boolean(data?.name?.trim() || data?.ticker?.trim());
if (!useful && data && !identity.figi) if (!useful && data && !identity.figi) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
} }
if (!useful && identity.figi) { if (!useful && identity.figi) {
const data = await this.tbank.getInstrumentByFigi( const data = await this.tbank.getInstrumentByFigi(
token, token,
identity.figi, identity.figi,
); );
if (data?.figi && data.figi !== identity.figi) if (data?.figi && data.figi !== identity.figi) {
throw new Error('Metadata FIGI mismatch'); 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'); 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'); throw new Error('Metadata source unavailable');
}
results.push({ type: 'figi', id: identity.figi, data }); results.push({ type: 'figi', id: identity.figi, data });
// A successful FIGI response identifies its canonical UID too; // A successful FIGI response identifies its canonical UID too;
// a failed UID RPC is never persisted as a negative lookup. // a failed UID RPC is never persisted as a negative lookup.
@@ -160,8 +171,9 @@ export class InstrumentMetadataService {
uidFailed && uidFailed &&
data?.uid === identity.instrumentUid && data?.uid === identity.instrumentUid &&
identity.instrumentUid identity.instrumentUid
) ) {
results.push({ type: 'uid', id: identity.instrumentUid, data }); results.push({ type: 'uid', id: identity.instrumentUid, data });
}
} }
if ( if (
!results.length || !results.length ||
@@ -170,8 +182,9 @@ export class InstrumentMetadataService {
(result) => (result) =>
result.data?.name?.trim() || result.data?.ticker?.trim(), result.data?.name?.trim() || result.data?.ticker?.trim(),
)) ))
) ) {
throw new Error('Incomplete metadata'); throw new Error('Incomplete metadata');
}
success = await this.repository.publish( success = await this.repository.publish(
attempt, attempt,
results, results,
@@ -179,12 +192,13 @@ export class InstrumentMetadataService {
); );
} }
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt); await this.access.rejectMembershipToken(attempt);
else } else {
this.logger.event('warn', 'instrument-metadata.failed', { this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
}
} }
// Remove the producer before awaiting finalization: a new request cannot // Remove the producer before awaiting finalization: a new request cannot
// attach to a producer whose observer list has already been drained. // attach to a producer whose observer list has already been drained.
@@ -196,7 +210,7 @@ export class InstrumentMetadataService {
} }
} catch { } catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', { this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}); });
this.active.clear(); this.active.clear();
this.producers.clear(); this.producers.clear();
@@ -1,162 +0,0 @@
import {
BadRequestException,
ConflictException,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {},
}));
import type { TbankAccountContext } from './tbank-access.service';
import { TbankAccessService } from './tbank-access.service';
import { TbankTokenService } from './tbank-token.service';
describe('TbankAccessService', () => {
const prisma = { tbankConnection: { findUnique: jest.fn() } };
const token = {
decrypt: jest.fn(),
translateInvalidToken: jest.fn((operation: () => Promise<unknown>) =>
new TbankTokenService({
getOrThrow: () => 'MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=',
} as never).translateInvalidToken(operation),
),
};
const tbank = { getOpenAccounts: jest.fn() };
const service = new TbankAccessService(
prisma as never,
token as never,
tbank as never,
);
beforeEach(() => {
jest.clearAllMocks();
prisma.tbankConnection.findUnique.mockResolvedValue({
encryptedToken: 'encrypted',
});
token.decrypt.mockReturnValue('plain-token');
tbank.getOpenAccounts.mockResolvedValue([
{
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: ' Основной ',
openedDate: { seconds: '1723507200' },
},
{ id: 'debit', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
]);
});
it('lists supported accounts through the saved decrypted token', async () => {
await expect(service.listSupportedAccounts('user')).resolves.toEqual([
{
id: 'account',
type: 'BROKERAGE',
name: 'Основной',
openedOn: '2024-08-13',
},
]);
expect(prisma.tbankConnection.findUnique).toHaveBeenCalledWith({
where: { userId: 'user' },
});
expect(token.decrypt).toHaveBeenCalledWith('encrypted');
expect(tbank.getOpenAccounts).toHaveBeenCalledWith('plain-token');
});
it('rejects missing connections before calling T-Bank', async () => {
prisma.tbankConnection.findUnique.mockResolvedValue(null);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(ConflictException),
);
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('resolves the persisted connection generation without an upstream account call', async () => {
const context = await service.withConnection('user', (value) => value);
expect(context).toEqual({
token: 'plain-token',
generation: expect.any(String) as string,
});
expect(tbank.getOpenAccounts).not.toHaveBeenCalled();
});
it('translates invalid tokens and preserves unavailable upstream errors', async () => {
tbank.getOpenAccounts.mockRejectedValueOnce(
new Error('INVALID_TBANK_TOKEN'),
);
await expect(service.listSupportedAccounts('user')).rejects.toEqual(
expect.any(BadRequestException),
);
const unavailable = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
tbank.getOpenAccounts.mockRejectedValueOnce(unavailable);
await expect(service.listSupportedAccounts('user')).rejects.toBe(
unavailable,
);
});
it('wraps account-scoped handlers with resolved account access', async () => {
const handler = jest.fn((context: TbankAccountContext) =>
Promise.resolve({
resolvedToken: context.token,
generation: context.generation,
accountId: context.account.id,
}),
);
await expect(
service.withAccount('user', 'account', handler),
).resolves.toEqual({
resolvedToken: 'plain-token',
generation: expect.any(String) as string,
accountId: 'account',
});
});
it('derives a stable opaque generation from each persisted encrypted payload', async () => {
const generation = () =>
service.withAccount('user', 'account', ({ generation: value }) =>
Promise.resolve(value),
);
prisma.tbankConnection.findUnique
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:first-nonce' })
.mockResolvedValueOnce({ encryptedToken: 'encrypted:second-nonce' });
const first = await generation();
const sameRecord = await generation();
const replaced = await generation();
expect(sameRecord).toBe(first);
expect(replaced).not.toBe(first);
expect(first).not.toContain('plain-token');
expect(first).not.toContain('encrypted:first-nonce');
});
it('rejects inaccessible accounts before running the handler', async () => {
const handler = jest.fn();
await expect(
service.withAccount('user', 'missing', handler),
).rejects.toEqual(expect.any(NotFoundException));
expect(handler).not.toHaveBeenCalled();
});
it('translates invalid-token handler failures and preserves unrelated handler failures', async () => {
await expect(
service.withAccount('user', 'account', () =>
Promise.reject(new Error('INVALID_TBANK_TOKEN')),
),
).rejects.toEqual(expect.any(BadRequestException));
const failure = new Error('source failed');
await expect(
service.withAccount('user', 'account', () => Promise.reject(failure)),
).rejects.toBe(failure);
});
});
+70 -111
View File
@@ -1,34 +1,28 @@
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { import { supportedTbankAccounts } from './tbank-account.policy';
SupportedTbankAccount, import { TbankErrorCode } from './tbank-error-code';
accessibleTbankAccount, import { tbankSourceNeedsRefresh } from './tbank-source.policy';
supportedTbankAccounts,
} from './tbank-account.policy';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankAccount, TbankClient } from './tbank.client';
export type TbankConnectionContext = { const EXPECTED_UPDATED_CONNECTIONS = 1;
token: string; const MEMBERSHIP_REVISION_INCREMENT = 1;
generation: string; const UNCHANGED_MEMBERSHIP_REVISION_INCREMENT = 0;
};
export type TbankAccountContext = TbankConnectionContext & {
account: TbankAccount;
};
export type TbankSyncAttempt = { export type TbankSyncAttempt = {
userId: string;
revision: string;
id: string;
attemptId: string; attemptId: string;
id: string;
revision: string;
userId: string;
}; };
@Injectable() @Injectable()
@@ -36,7 +30,6 @@ export class TbankAccessService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly tokens: TbankTokenService, private readonly tokens: TbankTokenService,
private readonly tbank: TbankClient,
) {} ) {}
async reserveMembership( async reserveMembership(
@@ -62,8 +55,9 @@ export class TbankAccessService {
connection.membershipAt, connection.membershipAt,
new Date(), new Date(),
) )
) ) {
return null; return null;
}
const scope = { userId, revision, scopeKey: 'membership' }; const scope = { userId, revision, scopeKey: 'membership' };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
@@ -72,8 +66,9 @@ export class TbankAccessService {
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
active.has(previous.attemptId) active.has(previous.attemptId)
) ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
}
const data = { const data = {
attemptId: randomUUID(), attemptId: randomUUID(),
processRunId, processRunId,
@@ -109,12 +104,12 @@ export class TbankAccessService {
state: 'running', state: 'running',
connection: { invalidToken: false }, connection: { invalidToken: false },
}, },
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
}); });
return result.count === 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({ const work = await this.prisma.tbankSyncWork.findFirst({
where: { where: {
...attempt, ...attempt,
@@ -126,6 +121,30 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
async onlineToken(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return this.tokens.decrypt(connection.encryptedToken);
}
async current(userId: string): Promise<{ revision: string; token: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -133,8 +152,9 @@ export class TbankAccessService {
completeWork = false, completeWork = false,
): Promise<boolean> { ): Promise<boolean> {
const supported = supportedTbankAccounts(accounts); 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'); throw new Error('Duplicate T-Bank account identity');
}
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and // Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below. // attempt supersession cannot interleave with the publication below.
@@ -160,6 +180,7 @@ export class TbankAccessService {
const changed = const changed =
previous.length !== next.length || previous.length !== next.length ||
previous.some((account, index) => { 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]; const incoming = next[index];
return ( return (
account.accountId !== incoming.accountId || account.accountId !== incoming.accountId ||
@@ -192,14 +213,26 @@ export class TbankAccessService {
where: { userId_revision: { userId, revision } }, where: { userId_revision: { userId, revision } },
data: { data: {
membershipAt: fetchedAt, 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({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
data: { state: 'complete', progress: { accounts: supported.length } }, data: { state: 'complete', progress: { accounts: supported.length } },
}); });
}
return true; return true;
}); });
} }
@@ -208,7 +241,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({ const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' }, where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
if (!claimed.count) return false; if (!claimed.count) return false;
await tx.tbankConnection.update({ await tx.tbankConnection.update({
@@ -238,94 +271,20 @@ export class TbankAccessService {
accounts: { orderBy: { upstreamOrder: 'asc' } }, accounts: { orderBy: { upstreamOrder: 'asc' } },
}, },
}); });
if (!connection) if (!connection) {
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' }); throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) }
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if ( if (
connection.membershipAt && connection.membershipAt &&
accountId && accountId &&
accountId !== 'all' && accountId !== 'all' &&
!connection.accounts.some((account) => account.accountId === accountId) !connection.accounts.some((account) => account.accountId === accountId)
) ) {
throw new NotFoundException(); throw new NotFoundException();
}
return connection; return connection;
} }
async listSupportedAccounts(
userId: string,
): Promise<SupportedTbankAccount[]> {
return this.withSupportedAccounts(userId, ({ accounts }) => accounts);
}
async withSupportedAccounts<T>(
userId: string,
handler: (context: {
token: string;
generation: string;
accounts: SupportedTbankAccount[];
}) => Promise<T> | T,
): Promise<T> {
return this.withConnection(userId, async ({ token, generation }) =>
handler({
token,
generation,
accounts: supportedTbankAccounts(
await this.tbank.getOpenAccounts(token),
),
}),
);
}
async withConnection<T>(
userId: string,
handler: (context: TbankConnectionContext) => Promise<T> | T,
): Promise<T> {
const connection = await this.connection(userId);
return this.tokens.translateInvalidToken(async () => handler(connection));
}
async withAccount<T>(
userId: string,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
return this.withConnection(userId, (connection) =>
this.withAccountForConnection(connection, accountId, handler),
);
}
async withAccountForConnection<T>(
connection: TbankConnectionContext,
accountId: string,
handler: (context: TbankAccountContext) => Promise<T>,
): Promise<T> {
const { token, generation } = connection;
return this.tokens.translateInvalidToken(async () => {
const account = accessibleTbankAccount(
await this.tbank.getOpenAccounts(token),
accountId,
);
if (!account) throw new NotFoundException();
return handler({ token, generation, account });
});
}
private async connection(
userId: string,
): Promise<{ token: string; generation: string }> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
});
if (!connection)
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
if (connection.invalidToken)
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
return {
token: this.tokens.decrypt(connection.encryptedToken),
generation: createHash('sha256')
.update(connection.encryptedToken)
.digest('base64url'),
};
}
} }
@@ -1,7 +1,4 @@
import { import { supportedTbankAccounts } from './tbank-account.policy';
accessibleTbankAccount,
supportedTbankAccounts,
} from './tbank-account.policy';
describe('tbank-account-policy', () => { describe('tbank-account-policy', () => {
it('normalizes supported account types and fallback names', () => { it('normalizes supported account types and fallback names', () => {
@@ -45,26 +42,4 @@ describe('tbank-account-policy', () => {
}, },
]); ]);
}); });
it('matches account-scoped access only for supported account types', () => {
expect(
accessibleTbankAccount(
[
{ id: 'account', type: 'ACCOUNT_TYPE_DEBIT', name: 'Debit' },
{ id: 'other', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Other' },
],
'account',
),
).toBeNull();
expect(
accessibleTbankAccount(
[{ id: 'account', type: 'ACCOUNT_TYPE_TINKOFF', name: 'Brokerage' }],
'account',
),
).toEqual({
id: 'account',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Brokerage',
});
});
}); });
+9 -19
View File
@@ -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'; import { TbankAccount } from './tbank.client';
const accountTypes = { const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' }, [TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: { [TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT', type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС', name: 'ИИС',
}, },
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' }, [TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: { [TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND', type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка', name: 'Фонд денежного рынка',
}, },
@@ -18,8 +19,8 @@ type AccountType = keyof typeof accountTypes;
export type SupportedTbankAccount = { export type SupportedTbankAccount = {
id: string; id: string;
name: string; name: string;
openedOn: null | string;
type: (typeof accountTypes)[AccountType]['type']; type: (typeof accountTypes)[AccountType]['type'];
openedOn: string | null;
}; };
export function supportedTbankAccounts( export function supportedTbankAccounts(
@@ -39,20 +40,9 @@ export function supportedTbankAccounts(
}); });
} }
export function accessibleTbankAccount( function openedOn(seconds: number | string | undefined): null | string {
accounts: TbankAccount[],
accountId: string,
): TbankAccount | null {
return (
accounts.find(
(account) => account.id === accountId && account.type in accountTypes,
) ?? null
);
}
function openedOn(seconds: string | number | undefined): string | null {
if (seconds === undefined) return null; if (seconds === undefined) return null;
const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND); const date = new Date(Number(seconds) * MILLISECONDS_PER_SECOND);
if (Number.isNaN(date.valueOf())) return null; 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'; } from './tbank.client';
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
uid?: string;
figi?: string;
assetUid?: string;
classCode?: string;
realExchange?: string;
apiTradeAvailableFlag?: boolean; apiTradeAvailableFlag?: boolean;
assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string;
countryOfRisk?: string;
currency?: string; currency?: string;
shareType?: string; figi?: string;
instrumentKind?: string;
sector?: string;
lot?: number;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
instrumentKind?: string;
lot?: number;
realExchange?: string;
sector?: string;
shareType?: string;
uid?: string;
}; };
export type TbankCandle = { export type TbankCandle = {
time?: TbankTimestamp;
close?: TbankDecimal; close?: TbankDecimal;
isComplete?: boolean; isComplete?: boolean;
time?: TbankTimestamp;
}; };
export type TbankLastPrice = { export type TbankLastPrice = {
instrumentUid?: string; instrumentUid?: string;
@@ -32,38 +34,38 @@ export type TbankLastPrice = {
export type TbankFundamentals = { export type TbankFundamentals = {
assetUid?: string; assetUid?: string;
currency?: string; currency?: string;
ebitdaTtm?: number;
marketCapitalization?: number; marketCapitalization?: number;
netDebtToEbitda?: number;
netIncomeTtm?: number;
peRatioTtm?: number; peRatioTtm?: number;
priceToBookTtm?: number; priceToBookTtm?: number;
revenueTtm?: number; revenueTtm?: number;
netIncomeTtm?: number;
ebitdaTtm?: number;
roe?: number; roe?: number;
totalDebtMrq?: number; totalDebtMrq?: number;
netDebtToEbitda?: number;
}; };
export type TbankBond = Omit<TbankShare, 'shareType'> & { export type TbankBond = Omit<TbankShare, 'shareType'> & {
nominal?: TbankMoney;
initialNominal?: TbankMoney;
aciValue?: TbankMoney; aciValue?: TbankMoney;
maturityDate?: TbankTimestamp; amortizationFlag?: boolean;
bondType?: string;
callDate?: TbankTimestamp; callDate?: TbankTimestamp;
couponQuantityPerYear?: number; couponQuantityPerYear?: number;
floatingCouponFlag?: boolean; floatingCouponFlag?: boolean;
initialNominal?: TbankMoney;
maturityDate?: TbankTimestamp;
nominal?: TbankMoney;
perpetualFlag?: boolean; perpetualFlag?: boolean;
amortizationFlag?: boolean;
subordinatedFlag?: boolean;
bondType?: string;
riskLevel?: string; riskLevel?: string;
subordinatedFlag?: boolean;
}; };
export type TbankCoupon = { export type TbankCoupon = {
couponNumber?: string;
couponDate?: TbankTimestamp; couponDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponNumber?: string;
couponPeriod?: number;
couponStartDate?: TbankTimestamp;
couponType?: string;
fixDate?: TbankTimestamp; fixDate?: TbankTimestamp;
payOneBond?: TbankMoney; payOneBond?: TbankMoney;
couponType?: string;
couponStartDate?: TbankTimestamp;
couponEndDate?: TbankTimestamp;
couponPeriod?: number;
}; };
@@ -1,10 +1,12 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { import {
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@Injectable() @Injectable()
@@ -36,7 +38,7 @@ export class TbankMembershipService {
.catch(() => .catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', { this.logger.event('error', 'tbank.membership.persistence_failed', {
userId, userId,
code: 'TBANK_UNAVAILABLE', code: TbankErrorCode.Unavailable,
}), }),
) )
.finally(() => this.active.delete(attemptId)); .finally(() => this.active.delete(attemptId));
@@ -61,7 +63,7 @@ export class TbankMembershipService {
} catch (error) { } catch (error) {
await this.access.failMembership( await this.access.failMembership(
attempt, attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN', error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
); );
} }
} }
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+74
View File
@@ -0,0 +1,74 @@
// String wire values for the protocol subset used by this integration.
// proto-loader decodes enums as strings; vendor contracts remain the source.
export enum InstrumentIdType {
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
Uid = 'INSTRUMENT_ID_TYPE_UID',
}
export enum TbankInstrumentStatus {
All = 'INSTRUMENT_STATUS_ALL',
}
export enum TbankInstrumentType {
Bond = 'INSTRUMENT_TYPE_BOND',
Share = 'INSTRUMENT_TYPE_SHARE',
}
export enum TbankAccountStatus {
Open = 'ACCOUNT_STATUS_OPEN',
}
export enum TbankAccountType {
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
}
export enum TbankCandleInterval {
Day = 'CANDLE_INTERVAL_DAY',
}
export enum TbankOperationState {
Executed = 'OPERATION_STATE_EXECUTED',
}
export enum TbankCouponType {
Floating = 'COUPON_TYPE_FLOATING',
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
}
export enum TbankOperationType {
BondTax = 'OPERATION_TYPE_BOND_TAX',
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
Buy = 'OPERATION_TYPE_BUY',
BuyCard = 'OPERATION_TYPE_BUY_CARD',
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
Coupon = 'OPERATION_TYPE_COUPON',
DivExt = 'OPERATION_TYPE_DIV_EXT',
Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
Input = 'OPERATION_TYPE_INPUT',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
Output = 'OPERATION_TYPE_OUTPUT',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
Tax = 'OPERATION_TYPE_TAX',
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
}
// Provider responses may contain values newer than our supported protocol subset.
export function isTbankProtocolValue(
value: unknown,
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
): boolean {
return value === expected;
}
@@ -3,11 +3,12 @@ import {
tbankSourceTtlMs, tbankSourceTtlMs,
} from './tbank-source.policy'; } from './tbank-source.policy';
describe('request-driven source freshness', () => { describe('mutable source freshness', () => {
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])( it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
'%s expires at its boundary without changing source time', '%s expires at its boundary without changing source time',
(source) => { (source) => {
const fetchedAt = new Date('2026-09-01Z'); 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]; const ttl = tbankSourceTtlMs[source];
expect( expect(
tbankSourceNeedsRefresh( tbankSourceNeedsRefresh(
@@ -1,15 +1,10 @@
// Freshness never establishes completeness. Immutable ranges are planned by // Freshness never establishes completeness. Immutable ranges are planned by
// their coverage owners; these TTLs apply only to mutable source snapshots. // their coverage owners; these TTLs apply only to mutable source snapshots.
export const tbankSourceTtlMs = { export const tbankSourceTtlMs = {
membership: 300_000, membership: 3_600_000,
portfolio: 60_000, portfolio: 3_600_000,
quote: 60_000, quote: 3_600_000,
candleTail: 60_000, candleTail: 3_600_000,
operationsTail: 300_000,
metadata: 86_400_000,
catalog: 86_400_000,
company: 86_400_000,
calendar: 86_400_000,
} as const; } as const;
export function tbankSourceNeedsRefresh( export function tbankSourceNeedsRefresh(
@@ -21,6 +16,7 @@ export function tbankSourceNeedsRefresh(
return ( return (
force || force ||
!fetchedAt || !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] now.valueOf() - fetchedAt.valueOf() >= tbankSourceTtlMs[source]
); );
} }
@@ -1,6 +1,8 @@
import { BadRequestException, Injectable } from '@nestjs/common'; import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto'; import { TbankCrypto } from './tbank.crypto';
@Injectable() @Injectable()
@@ -25,8 +27,9 @@ export class TbankTokenService {
try { try {
return await operation(); return await operation();
} catch (error) { } catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error; throw error;
} }
} }
+13 -12
View File
@@ -1,18 +1,29 @@
// Public cross-module surface. Nest providers are exported by the matching module. // Public cross-module surface. Nest providers are exported by the matching module.
export { InstrumentMetadataService } from './instrument-metadata.service'; export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
TbankAccessService, TbankAccessService,
type TbankConnectionContext, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
type TbankCoupon, type TbankCoupon,
type TbankFundamentals, type TbankFundamentals,
type TbankLastPrice,
type TbankShare, type TbankShare,
} from './tbank-market.types'; } from './tbank-market.types';
export { TbankMembershipService } from './tbank-membership.service';
export {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from './tbank-source.policy';
export { TbankTokenService } from './tbank-token.service'; export { TbankTokenService } from './tbank-token.service';
export { supportedTbankAccounts } from './tbank-account.policy';
export { export {
TbankClient, TbankClient,
type TbankDividend, type TbankDividend,
@@ -24,13 +35,3 @@ export {
type TbankPositions, type TbankPositions,
type TbankTimestamp, type TbankTimestamp,
} from './tbank.client'; } 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';
+179 -63
View File
@@ -1,5 +1,7 @@
import { status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => { describe('TbankClient operations', () => {
@@ -9,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' }, to: { seconds: '2' },
cursor: 'cursor', cursor: 'cursor',
limit: 10, limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const, state: TbankOperationState.Executed,
}; };
it('passes a cursor operations request to OperationsService', async () => { it('passes a cursor operations request to OperationsService', async () => {
@@ -46,11 +48,27 @@ describe('TbankClient operations', () => {
seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)), seconds: String(Math.floor(Date.parse('2026-08-19T00:00:00Z') / 1000)),
}, },
to: { to: {
seconds: String(Math.floor(Date.parse('2026-09-18T00:00:00Z') / 1000)), seconds: String(Math.floor(Date.parse('2026-09-19T00:00:00Z') / 1000)),
}, },
}); });
}); });
it.each(['getDividends', 'getBondCoupons'] as const)(
'%s converts a leap-day calendar window to a full half-open UTC day',
async (method) => {
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',
from: { seconds: String(Date.parse('2024-02-29T00:00:00Z') / 1000) },
to: { seconds: String(Date.parse('2024-03-01T00:00:00Z') / 1000) },
});
},
);
it('propagates the normalized upstream error', async () => { it('propagates the normalized upstream error', async () => {
const client = Object.create(TbankClient.prototype) as TbankClient; const client = Object.create(TbankClient.prototype) as TbankClient;
const error = new ServiceUnavailableException({ const error = new ServiceUnavailableException({
@@ -96,11 +114,10 @@ describe('TbankClient operations', () => {
ok: true, ok: true,
}); });
const failed = invoke('token', {}, 'getPortfolio', {}); const failed = invoke('token', {}, 'getPortfolio', {});
const rejected = expect(failed).rejects.toEqual( await Promise.all([
expect.any(ServiceUnavailableException), expect(failed).rejects.toEqual(expect.any(ServiceUnavailableException)),
); jest.advanceTimersByTimeAsync(1000),
await jest.advanceTimersByTimeAsync(1000); ]);
await rejected;
expect(logger.event).toHaveBeenCalledWith( expect(logger.event).toHaveBeenCalledWith(
'debug', 'debug',
@@ -125,7 +142,7 @@ describe('TbankClient metadata retries', () => {
const instrument = { name: 'Облигация', ticker: 'BOND' }; const instrument = { name: 'Облигация', ticker: 'BOND' };
let client: TbankClient; let client: TbankClient;
let requestRpc: jest.Mock; let requestRpc: jest.Mock;
let queue: { size: number; run: jest.Mock }; let queue: { run: jest.Mock; size: number };
let logger: { let logger: {
event: jest.Mock<void, [string, string, Record<string, unknown>]>; event: jest.Mock<void, [string, string, Record<string, unknown>]>;
}; };
@@ -159,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
])( ])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses', 'recovers metadata after one retry for transient gRPC status %s',
async (code) => { async (code) => {
requestRpc requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999); await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2); expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument); await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}, },
); );
it('rechecks a full queue before admitting a metadata retry', async () => { it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50; queue.size = 50;
requestRpc.mockResolvedValue({ instrument }); requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi'); await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(1999);
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
queue.size = 49; expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
}); });
it('does not bypass queue admission after an upstream failure', async () => { it('does not bypass queue admission after an upstream failure', async () => {
@@ -206,46 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50; queue.size = 50;
await jest.advanceTimersByTimeAsync(2000); await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1); expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0; expect(await result).toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
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(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
it('stops retrying an unavailable upstream after three RPCs', async () => { it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
const result = expect( await Promise.all([
client.getInstrumentByUid('token', 'uid'), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
).rejects.toBeInstanceOf(ServiceUnavailableException); jest.advanceTimersByTimeAsync(1000),
]);
await jest.advanceTimersByTimeAsync(6000); expect(queue.run).toHaveBeenCalledTimes(2);
await result;
expect(queue.run).toHaveBeenCalledTimes(3);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
@@ -267,6 +244,7 @@ describe('TbankClient metadata retries', () => {
async (method) => { async (method) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND }); 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(); await expect(client[method]('token', 'instrument')).resolves.toBeNull();
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
@@ -304,3 +282,141 @@ describe('TbankClient metadata retries', () => {
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
}); });
describe('TbankClient share catalog', () => {
const token = 'token';
const catalogSize = 40;
let client: TbankClient;
let call: jest.Mock;
let logger: {
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
};
beforeEach(() => {
call = jest.fn();
logger = {
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
};
client = Object.assign(
Object.create(TbankClient.prototype) as TbankClient,
{ call, logger },
);
});
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
call.mockResolvedValue({ instruments });
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
});
});
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
const catalog = Array.from({ length: catalogSize }, (_, index) => {
const fitting = index === catalogSize - 1;
return {
apiTradeAvailableFlag: fitting,
buyAvailableFlag: fitting,
ticker: fitting ? 'FIT' : `T${index}`,
uid: fitting ? 'uid-fit' : `uid-${index}`,
};
});
call.mockResolvedValue({ instruments: catalog });
const shares = await client.getAllShares(token);
expect(shares).toEqual(catalog);
expect(shares).toHaveLength(catalogSize);
expect(shares).toContainEqual({
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
ticker: 'FIT',
uid: 'uid-fit',
});
});
it('rejects when the source refuses instead of returning an empty success', async () => {
const error = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
call.mockRejectedValue(error);
await expect(client.getAllShares(token)).rejects.toBe(error);
});
it('rejects when the response carries no instrument list', async () => {
call.mockResolvedValue({});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an empty instrument list with the public unavailable code', async () => {
call.mockResolvedValue({ instruments: [] });
const failure = await client
.getAllShares(token)
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ServiceUnavailableException);
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
code: 'TBANK_UNAVAILABLE',
});
});
it('rejects a non-list instrument payload as uninterpretable', async () => {
call.mockResolvedValue({ instruments: 'corrupted' });
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an uninterpretable entry without instrument identity', async () => {
call.mockResolvedValue({
instruments: [
{ ticker: 'FIT', uid: 'uid-fit' },
{ ticker: 'BROKEN' },
],
});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
call.mockResolvedValue({
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
});
await client.getAllShares(token);
expect(logger.event).toHaveBeenCalledWith(
'info',
'tbank.shares.catalog.fetched',
expect.objectContaining({ count: 2, operation: 'shares' }),
);
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
});
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
call.mockResolvedValue({ instruments: [] });
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.shares.catalog.unusable',
expect.objectContaining({ operation: 'shares' }),
);
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
'secret-token',
);
});
});
+136 -97
View File
@@ -1,4 +1,3 @@
import { MILLISECONDS_PER_SECOND } from '../../common/time';
import { import {
ChannelCredentials, ChannelCredentials,
Client, Client,
@@ -17,8 +16,6 @@ import {
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { StructuredLogger } from '../../common/logging/structured-logger.service';
import { Environment } from '../../config/env.validation';
import type { import type {
TbankBond, TbankBond,
@@ -29,11 +26,29 @@ import type {
TbankShare, TbankShare,
} from './tbank-market.types'; } 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 RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50; const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000; const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
@@ -42,78 +57,78 @@ type Unary<Request, Response> = (
request: Request, request: Request,
metadata: Metadata, metadata: Metadata,
options: { deadline: Date }, options: { deadline: Date },
callback: (error: ServiceError | null, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; 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 TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
id: string; id: string;
type: string;
name: string; name: string;
openedDate?: { seconds?: string | number }; openedDate?: { seconds?: number | string };
type: string;
}; };
export type TbankPortfolio = { export type TbankPortfolio = {
positions?: { positions?: {
figi?: string;
instrumentUid?: string;
instrumentType?: string;
ticker?: string;
quantity?: TbankDecimal;
currentPrice?: TbankMoney;
averagePositionPrice?: TbankMoney; averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: TbankDecimal;
ticker?: string;
}[]; }[];
}; };
export type TbankPositions = { money?: TbankMoney[] }; export type TbankPositions = { money?: TbankMoney[] };
export type TbankInstrument = { export type TbankInstrument = {
uid?: string; brand?: { logoName?: null | string };
figi?: string; figi?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string;
name?: string;
isin?: string; isin?: string;
brand?: { logoName?: string | null }; name?: string;
ticker?: string;
uid?: string;
}; };
export type TbankDividend = { export type TbankDividend = {
dividendNet?: TbankMoney; dividendNet?: TbankMoney;
recordDate?: TbankTimestamp;
paymentDate?: TbankTimestamp;
lastBuyDate?: TbankTimestamp;
dividendType?: string; 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 = { export type TbankOperationItem = {
state?: string; accruedInt?: TbankMoney;
commission?: TbankMoney;
cursor?: string; cursor?: string;
id?: string;
name?: string;
date?: TbankTimestamp; date?: TbankTimestamp;
type?: string;
description?: string; description?: string;
figi?: string; figi?: string;
instrumentUid?: string; id?: string;
instrumentType?: string; instrumentType?: string;
ticker?: string; instrumentUid?: string;
name?: string;
payment?: TbankMoney; payment?: TbankMoney;
price?: TbankMoney; price?: TbankMoney;
commission?: TbankMoney; quantity?: number | string;
accruedInt?: TbankMoney; quantityDone?: number | string;
quantity?: string | number; state?: string;
quantityDone?: string | number; ticker?: string;
type?: string;
}; };
export type TbankOperationsByCursorRequest = { export type TbankOperationsByCursorRequest = {
accountId: string; accountId: string;
instrumentId?: string;
from?: TbankTimestamp;
to?: TbankTimestamp;
cursor?: string; cursor?: string;
from?: TbankTimestamp;
instrumentId?: string;
limit?: number; limit?: number;
state: 'OPERATION_STATE_EXECUTED'; state: TbankOperationState.Executed;
to?: TbankTimestamp;
}; };
export type TbankOperationsByCursorResponse = { export type TbankOperationsByCursorResponse = {
hasNext?: boolean; hasNext?: boolean;
nextCursor?: string;
items?: TbankOperationItem[]; items?: TbankOperationItem[];
nextCursor?: string;
}; };
type TbankNamespace = { type TbankNamespace = {
tinkoff: { tinkoff: {
@@ -123,7 +138,7 @@ type TbankNamespace = {
class RateLimiter { class RateLimiter {
private readonly waiting: (() => void)[] = []; private readonly waiting: (() => void)[] = [];
private starts = 0; private starts = INITIAL_REQUEST_COUNT;
private windowStartedAt = Date.now(); private windowStartedAt = Date.now();
constructor(private readonly perSecond: number) {} constructor(private readonly perSecond: number) {}
@@ -148,16 +163,16 @@ class RateLimiter {
const now = Date.now(); const now = Date.now();
if (now - this.windowStartedAt >= RATE_WINDOW_MS) { if (now - this.windowStartedAt >= RATE_WINDOW_MS) {
this.windowStartedAt = now; this.windowStartedAt = now;
this.starts = 0; this.starts = INITIAL_REQUEST_COUNT;
} }
while (this.starts < this.perSecond && this.waiting.length) { while (this.starts < this.perSecond && this.waiting.length) {
this.starts += 1; this.starts++;
this.waiting.shift()?.(); this.waiting.shift()?.();
} }
if (this.waiting.length && this.starts === this.perSecond) { if (this.waiting.length && this.starts === this.perSecond) {
setTimeout( setTimeout(
() => this.drain(), () => 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; ).tinkoff.public.invest.api.contract.v1;
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH'); const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
const credentials = caPath const credentials = caPath
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
? ChannelCredentials.createSsl(readFileSync(caPath)) ? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl(); : ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT'); const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) => const create = (name: string) =>
new ( new (
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
services[name] as new ( services[name] as new (
address: string, address: string,
credentials: ChannelCredentials, credentials: ChannelCredentials,
@@ -246,7 +263,7 @@ export class TbankClient {
token, token,
this.users, this.users,
'getAccounts', 'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' }, { status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []); ).then((response) => response.accounts ?? []);
} }
@@ -268,15 +285,15 @@ export class TbankClient {
getInstrumentByFigi( getInstrumentByFigi(
token: string, token: string,
figi: string, figi: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi); return this.getInstrument(token, InstrumentIdType.Figi, figi);
} }
getInstrumentByUid( getInstrumentByUid(
token: string, token: string,
instrumentUid: string, instrumentUid: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid); return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
} }
getDividends( getDividends(
@@ -292,7 +309,7 @@ export class TbankClient {
{ {
instrumentId, instrumentId,
from: { seconds: String(utcSeconds(from)) }, from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) }, to: { seconds: String(utcSeconds(to, true)) },
}, },
).then((response) => response.dividends ?? []); ).then((response) => response.dividends ?? []);
} }
@@ -304,11 +321,40 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE', instrumentKind: TbankInstrumentType.Share,
}, },
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
async getAllShares(token: string): Promise<TbankShare[]> {
const response = await this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'shares',
{ instrumentStatus: TbankInstrumentStatus.All },
);
const { instruments } = response;
if (
!Array.isArray(instruments) ||
!instruments.length ||
instruments.some((item) => !item?.uid)
) {
this.logger.event('error', 'tbank.shares.catalog.unusable', {
code: TbankErrorCode.Unavailable,
operation: 'shares',
});
throw new ServiceUnavailableException({
code: TbankErrorCode.Unavailable,
});
}
this.logger.event('info', 'tbank.shares.catalog.fetched', {
asOf: new Date().toISOString(),
count: instruments.length,
operation: 'shares',
});
return instruments;
}
findBonds(token: string, query: string): Promise<TbankBond[]> { findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>( return this.call<{ instruments?: TbankBond[] }>(
token, token,
@@ -316,21 +362,23 @@ export class TbankClient {
'findInstrument', 'findInstrument',
{ {
query, query,
instrumentKind: 'INSTRUMENT_TYPE_BOND', instrumentKind: TbankInstrumentType.Bond,
}, },
).then((response) => response.instruments ?? []); ).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 }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -344,24 +392,24 @@ export class TbankClient {
{ {
instrumentId: uid, instrumentId: uid,
from: { seconds: String(utcSeconds(from)) }, from: { seconds: String(utcSeconds(from)) },
to: { seconds: String(utcSeconds(to)) }, to: { seconds: String(utcSeconds(to, true)) },
}, },
).then((response) => response.events ?? []); ).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 }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType: 'INSTRUMENT_ID_TYPE_UID', idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetDescription(token: string, assetUid: string): Promise<string | null> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
this.instruments, this.instruments,
@@ -375,7 +423,7 @@ export class TbankClient {
getFundamentals( getFundamentals(
token: string, token: string,
assetUid: string, assetUid: string,
): Promise<TbankFundamentals | null> { ): Promise<null | TbankFundamentals> {
return this.call<{ fundamentals?: TbankFundamentals[] }>( return this.call<{ fundamentals?: TbankFundamentals[] }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
@@ -399,13 +447,7 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).then((response) => response.lastPrices ?? []);
} }
getLastPrice(token: string, uid: string): Promise<TbankLastPrice | null> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<string | null> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
this.marketData, this.marketData,
@@ -434,7 +476,7 @@ export class TbankClient {
to: { to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)), seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
}, },
interval: 'CANDLE_INTERVAL_DAY', interval: TbankCandleInterval.Day,
limit: 2400, limit: 2400,
}, },
).then((response) => response.candles ?? []); ).then((response) => response.candles ?? []);
@@ -442,9 +484,9 @@ export class TbankClient {
private async getInstrument( private async getInstrument(
token: string, token: string,
idType: 'INSTRUMENT_ID_TYPE_UID' | 'INSTRUMENT_ID_TYPE_FIGI', idType: InstrumentIdType,
id: string, id: string,
): Promise<TbankInstrument | null> { ): Promise<null | TbankInstrument> {
try { try {
const response = await this.call<{ instrument?: TbankInstrument }>( const response = await this.call<{ instrument?: TbankInstrument }>(
token, token,
@@ -466,13 +508,13 @@ export class TbankClient {
request: object, request: object,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
for (let attempts = 1; attempts <= maxAttempts; attempts += 1) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
if (queueFull) if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
const response = await this.queue.run(() => const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request), this.request<Response>(token, client, method, request),
); );
@@ -485,52 +527,45 @@ export class TbankClient {
} catch (error) { } catch (error) {
const grpcCode = (error as ServiceError).code; const grpcCode = (error as ServiceError).code;
const failureContext = queueFull const failureContext = queueFull
? { code: 'TBANK_UNAVAILABLE' } ? { code: TbankErrorCode.Unavailable }
: { grpcCode }; : { grpcCode };
const retryable = queueFull const retryable = !queueFull && [
? metadataCall status.DEADLINE_EXCEEDED,
: [ status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE, status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, ].includes(grpcCode);
status.RESOURCE_EXHAUSTED, if (retryable && attempts < RPC_MAX_ATTEMPTS) {
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue; continue;
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? 0 : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( 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. // eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN'); throw new Error(TbankErrorCode.InvalidToken);
}
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall) (method === 'shareBy' || metadataCall)
) ) {
throw new NotFoundException(); throw new NotFoundException();
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); }
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
} }
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
private request<Response>( private request<Response>(
@@ -542,6 +577,7 @@ export class TbankClient {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); 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]( client[method](
request as never, request as never,
metadata, metadata,
@@ -552,6 +588,9 @@ export class TbankClient {
} }
} }
function utcSeconds(date: string): number { // Calendar owners supply inclusive UTC days; RPC timestamps use [from, next(to)).
return Math.floor(Date.parse(`${date}T00:00:00Z`) / MILLISECONDS_PER_SECOND); function utcSeconds(date: string, nextDay = false): number {
const instant = new Date(`${date}T00:00:00Z`);
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'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto';
const VERSION_SIZE_BYTES = 1;
const KEY_SIZE_BYTES = 32; const KEY_SIZE_BYTES = 32;
const VERSION = 1; const VERSION = 1;
@@ -11,8 +13,9 @@ export class TbankCrypto {
static fromBase64(key: string): TbankCrypto { static fromBase64(key: string): TbankCrypto {
const decoded = Buffer.from(key, 'base64'); 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'); throw new Error('Invalid token encryption key');
}
return new TbankCrypto(decoded); return new TbankCrypto(decoded);
} }
@@ -33,15 +36,15 @@ export class TbankCrypto {
decrypt(payload: string): string { decrypt(payload: string): string {
const bytes = Buffer.from(payload, 'base64'); 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'); 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 tag = bytes.subarray(-TAG_SIZE);
const decipher = createDecipheriv('aes-256-gcm', this.key, nonce); const decipher = createDecipheriv('aes-256-gcm', this.key, nonce);
decipher.setAuthTag(tag); decipher.setAuthTag(tag);
return Buffer.concat([ 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(), decipher.final(),
]).toString('utf8'); ]).toString('utf8');
} }
+2 -1
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankMembershipService } from './tbank-membership.service';
import { InstrumentMetadataRepository } from './instrument-metadata.repository'; import { InstrumentMetadataRepository } from './instrument-metadata.repository';
import { InstrumentMetadataService } from './instrument-metadata.service'; import { InstrumentMetadataService } from './instrument-metadata.service';
import { TbankAccessService } from './tbank-access.service'; import { TbankAccessService } from './tbank-access.service';
import { TbankMembershipService } from './tbank-membership.service';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
+18 -8
View File
@@ -1,16 +1,20 @@
import { INestApplication, ValidationPipe } from '@nestjs/common'; import {
import { NestFactory } from '@nestjs/core'; BadRequestException,
import cookieParser from 'cookie-parser'; INestApplication,
ValidationPipe,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core';
import { SwaggerModule } from '@nestjs/swagger'; import { SwaggerModule } from '@nestjs/swagger';
import { API_PREFIX, createOpenApiDocument } from './openapi'; import cookieParser from 'cookie-parser';
import { Logger } from 'nestjs-pino'; import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service'; import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
import { API_PREFIX, createOpenApiDocument } from './openapi';
export function configureApp(app: INestApplication): void { export function configureApp(app: INestApplication): void {
const config = app.get<ConfigService<Environment, true>>(ConfigService); const config = app.get<ConfigService<Environment, true>>(ConfigService);
@@ -24,9 +28,15 @@ export function configureApp(app: INestApplication): void {
}); });
app.setGlobalPrefix(API_PREFIX); app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser()); app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware); app.useGlobalPipes(
app.use(requestLogging.use.bind(requestLogging)); new ValidationPipe({
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true })); forbidUnknownValues: true,
transform: true,
whitelist: true,
validationError: { target: false, value: false },
exceptionFactory: (message) => new BadRequestException({ message }),
}),
);
app.useGlobalFilters(new HttpExceptionFilter()); app.useGlobalFilters(new HttpExceptionFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
} }
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
@@ -114,8 +114,8 @@ describe('AuthService', () => {
where, where,
data, data,
}: { }: {
where: { id?: string; refreshTokenHash?: string };
data: { refreshTokenHash: string }; data: { refreshTokenHash: string };
where: { id?: string; refreshTokenHash?: string };
}) => { }) => {
if (where.id === session.id && where.refreshTokenHash === currentHash) { if (where.id === session.id && where.refreshTokenHash === currentHash) {
currentHash = data.refreshTokenHash; currentHash = data.refreshTokenHash;
+57 -47
View File
@@ -6,9 +6,15 @@ import {
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
const REFRESH_TTL_MS = 604_800_000; const REFRESH_TTL_MS = 604_800_000;
@@ -16,6 +22,7 @@ const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
const DUMMY_PASSWORD_HASH = 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'; '$2b$12$Z49BwJsyiUqY0ZozFzt9IeYvjhV4kbeLw9KdxOguT5bfhA1RvPCx6';
@Injectable() @Injectable()
@@ -28,26 +35,27 @@ export class AuthService {
async register( async register(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const normalizedEmail = this.normalizeEmail(email);
const existing = await this.prisma.user.findUnique({ const existing = await this.prisma.user.findUnique({
where: { email: normalizedEmail }, where: { email },
}); });
if (existing) if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
let user: User; let user: User;
try { try {
user = await this.prisma.user.create({ user = await this.prisma.user.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
email: normalizedEmail, email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
}, },
}); });
} catch (error) { } catch (error) {
if (isUniqueConstraintError(error)) if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error; throw error;
} }
return this.createSession(user); return this.createSession(user);
@@ -56,32 +64,34 @@ export class AuthService {
async login( async login(
email: string, email: string,
password: string, password: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email: this.normalizeEmail(email) }, where: { email },
}); });
const valid = await bcrypt.compare( const valid = await bcrypt.compare(
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
); );
if (!user || !valid) if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' }); throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user); return this.createSession(user);
} }
async refresh( async refresh(
token: string, token: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
let payload: RefreshPayload; let payload: RefreshPayload;
try { try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
} catch { } catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (payload.type !== 'refresh')
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
const session = await this.prisma.session.findUnique({ const session = await this.prisma.session.findUnique({
where: { id: payload.sid }, where: { id: payload.sid },
include: { user: true }, include: { user: true },
@@ -92,37 +102,40 @@ export class AuthService {
session.revokedAt || session.revokedAt ||
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } }); await this.prisma.session.delete({ where: { id: session.id } });
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
if (session.refreshTokenHash !== this.hash(token))
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.rotateSession(session, session.refreshTokenHash); return this.rotateSession(session, session.refreshTokenHash);
} }
async logout(token?: string): Promise<void> { async logout(token?: string): Promise<void> {
if (!token) return; if (!token) return;
let payload: RefreshPayload;
try { try {
const payload = await this.jwt.verifyAsync<RefreshPayload>(token, { payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'], algorithms: ['HS256'],
}); });
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
}
} catch { } catch {
// Logout is intentionally idempotent. return;
}
if (payload.type !== 'refresh') return;
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if (
session &&
!session.revokedAt &&
session.refreshTokenHash === this.hash(token)
) {
await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
} }
} }
@@ -132,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } }); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
private async createSession( private async createSession(
user: User, user: User,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({ const session = await this.prisma.session.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -153,7 +166,7 @@ export class AuthService {
private async rotateSession( private async rotateSession(
session: Session & { user: User }, session: Session & { user: User },
expectedRefreshTokenHash?: string, expectedRefreshTokenHash?: string,
): Promise<{ user: AuthUser; accessToken: string; refreshToken: string }> { ): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync( const refreshToken = await this.jwt.signAsync(
{ {
sub: session.userId, sub: session.userId,
@@ -172,8 +185,9 @@ export class AuthService {
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash }, where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data, data,
}); });
if (updated.count !== 1) if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else { } else {
await this.prisma.session.update({ where: { id: session.id }, data }); await this.prisma.session.update({ where: { id: session.id }, data });
} }
@@ -187,10 +201,6 @@ export class AuthService {
}; };
} }
private normalizeEmail(email: string): string {
return email;
}
private hash(token: string): string { private hash(token: string): string {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
@@ -201,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' && typeof error === 'object' &&
error !== null && error !== null &&
'code' in error && 'code' in error &&
(error as { code?: unknown }).code === 'P2002' isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
); );
} }
+11 -5
View File
@@ -1,17 +1,23 @@
export interface AuthUser { export interface AuthUser {
id: string;
email: string; email: string;
id: string;
} }
export type AuthenticationResult = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export interface AccessPayload { export interface AccessPayload {
sub: string;
sid: string; sid: string;
sub: string;
type: 'access'; type: 'access';
} }
export interface RefreshPayload { export interface RefreshPayload {
sub: string;
sid: string;
type: 'refresh';
jti: string; 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 { ConfigModule, ConfigService } from '@nestjs/config';
import { JwtModule } from '@nestjs/jwt'; import { JwtModule } from '@nestjs/jwt';
import { PassportModule } from '@nestjs/passport'; import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthController } from './presentation/auth.controller';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@Module({ @Module({
imports: [ imports: [
@@ -2,6 +2,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@@ -17,8 +19,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
} }
validate(payload: AccessPayload): AccessPayload { validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return payload; return payload;
} }
} }
@@ -1,21 +0,0 @@
import { HTTP_CODE_METADATA } from '@nestjs/common/constants';
jest.mock('../application/auth.service', () => ({
AuthService: class AuthService {},
}));
import { AuthController } from './auth.controller';
describe('AuthController response statuses', () => {
it.each(['register', 'login', 'refresh'] as const)(
'%s explicitly returns HTTP 200',
(handler) => {
// Nest stores @HttpCode metadata on the unbound handler function.
const handlerTarget = Object.getOwnPropertyDescriptor(
AuthController.prototype,
handler,
)?.value as object;
expect(Reflect.getMetadata(HTTP_CODE_METADATA, handlerTarget)).toBe(200);
},
);
});
@@ -1,3 +1,5 @@
import type { Request, Response } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -17,11 +19,12 @@ import {
ApiProperty, ApiProperty,
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request, Response } from 'express';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; 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 { 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'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -82,14 +85,9 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { const token = this.refreshToken(request);
const token = this.refreshToken(request); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' }); return this.respond(response, await this.auth.refresh(token));
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
this.clearCookie(response);
throw error;
}
} }
@Post('logout') @Post('logout')
@@ -114,11 +112,7 @@ export class AuthController {
private respond( private respond(
response: Response, response: Response,
result: { result: AuthenticationResult,
user: { id: string; email: string };
accessToken: string;
refreshToken: string;
},
) { ) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions()); response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken }; return { user: result.user, accessToken: result.accessToken };
@@ -129,6 +123,7 @@ export class AuthController {
} }
private refreshToken(request: Request): string | undefined { 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]; const token: unknown = request.cookies?.[REFRESH_COOKIE];
return typeof token === 'string' ? token : undefined; return typeof token === 'string' ? token : undefined;
} }
+9 -4
View File
@@ -7,6 +7,9 @@ import {
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
const EMAIL_PART_COUNT = 2;
const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
@@ -19,18 +22,19 @@ class EmailConstraint {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
value.length <= MAX_EMAIL_LENGTH && value.length <= MAX_EMAIL_LENGTH &&
value.split('@').length === 2 && value.split('@').length === EMAIL_PART_COUNT &&
!value !value
.split('') .split('')
.some( .some(
(character) => (character) =>
/\s/.test(character) || /\s/.test(character) ||
character.charCodeAt(0) < ASCII_SPACE || character.charCodeAt(FIRST_CHARACTER_INDEX) < ASCII_SPACE ||
character.charCodeAt(0) === ASCII_DELETE, character.charCodeAt(FIRST_CHARACTER_INDEX) === ASCII_DELETE,
) && ) &&
value.split('@').every(Boolean) value.split('@').every(Boolean)
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must be a valid email address'; return 'must be a valid email address';
} }
@@ -44,6 +48,7 @@ class PasswordBytesConstraint {
Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES Buffer.byteLength(value, 'utf8') <= MAX_PASSWORD_BYTES
); );
} }
defaultMessage(): string { defaultMessage(): string {
return 'must not exceed 72 UTF-8 bytes'; return 'must not exceed 72 UTF-8 bytes';
} }
@@ -1,11 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { RequestContextService } from '../../../common/logging/request-context.service'; import { PinoLogger } from 'nestjs-pino';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) { constructor(private readonly logger: PinoLogger) {
super(); super();
} }
@@ -14,8 +15,9 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
const request = context const request = context
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.logger.assign({ userId: request.user.sub });
}
return Boolean(allowed); return Boolean(allowed);
} }
} }
@@ -1,40 +1,44 @@
import {
MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH,
} from '../domain/bank-card-limits';
import { import {
BadRequestException, BadRequestException,
ConflictException, ConflictException,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; 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 { import {
bankPaymentSystems, bankPaymentSystems,
BankPeriodQueryDto, BankPeriodQueryDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { isCalendarDate, moscowToday } from '../domain/bank-card.calculations';
import { parseRubMoney } from '../domain/rub-money';
export const BankErrorCode = { const NEXT_MONTH_OFFSET = 1;
InvalidInput: 'BANK_INVALID_INPUT', const PREVIOUS_MONTH_LAST_DAY = 0;
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND', export enum BankErrorCode {
InvalidMoney: 'BANK_INVALID_MONEY', CardArchived = 'BANK_CARD_ARCHIVED',
InvalidDate: 'BANK_INVALID_DATE', DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION', DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
InvalidRefund: 'BANK_INVALID_REFUND', ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
RefundLimit: 'BANK_REFUND_LIMIT', IntentConflict = 'BANK_INTENT_CONFLICT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS', InvalidDate = 'BANK_INVALID_DATE',
IntentConflict: 'BANK_INTENT_CONFLICT', InvalidInput = 'BANK_INVALID_INPUT',
OperationDeleted: 'BANK_OPERATION_DELETED', InvalidMoney = 'BANK_INVALID_MONEY',
CardArchived: 'BANK_CARD_ARCHIVED', InvalidRefund = 'BANK_INVALID_REFUND',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT', NotFound = 'BANK_NOT_FOUND',
WriteConflict: 'BANK_WRITE_CONFLICT', OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY', OperationDeleted = 'BANK_OPERATION_DELETED',
} as const; RefundLimit = 'BANK_REFUND_LIMIT',
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode]; VersionConflict = 'BANK_VERSION_CONFLICT',
WriteConflict = 'BANK_WRITE_CONFLICT',
}
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never { export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code }); throw new BadRequestException({ code });
@@ -44,8 +48,8 @@ export function periodBounds(query: BankPeriodQueryDto) {
const from = `${query.month}-01`; const from = `${query.month}-01`;
if (!isCalendarDate(from)) return fail(); if (!isCalendarDate(from)) return fail();
const end = new Date(`${from}T00:00:00Z`); const end = new Date(`${from}T00:00:00Z`);
end.setUTCMonth(end.getUTCMonth() + 1, 0); end.setUTCMonth(end.getUTCMonth() + NEXT_MONTH_OFFSET, PREVIOUS_MONTH_LAST_DAY);
return { from, to: end.toISOString().slice(0, 10) }; return { from, to: end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) };
} }
if ( if (
query.month || query.month ||
@@ -54,8 +58,9 @@ export function periodBounds(query: BankPeriodQueryDto) {
!isCalendarDate(query.from) || !isCalendarDate(query.from) ||
!isCalendarDate(query.to) || !isCalendarDate(query.to) ||
query.from > query.to query.from > query.to
) ) {
return fail(); return fail();
}
return { from: query.from, to: query.to }; return { from: query.from, to: query.to };
} }
export function conflict( export function conflict(
@@ -93,8 +98,9 @@ export function currentDate(value: string) {
if (value > moscowToday()) fail(BankErrorCode.InvalidDate); if (value > moscowToday()) fail(BankErrorCode.InvalidDate);
} }
export function name(value: string, max: number) { 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(); fail();
}
return value.trim(); return value.trim();
} }
export function cardInput(dto: CreateBankCardDto) { export function cardInput(dto: CreateBankCardDto) {
@@ -104,8 +110,9 @@ export function cardInput(dto: CreateBankCardDto) {
if ( if (
dto.paymentSystem != null && dto.paymentSystem != null &&
!bankPaymentSystems.some((system) => system === dto.paymentSystem) !bankPaymentSystems.some((system) => system === dto.paymentSystem)
) ) {
fail(); fail();
}
return { return {
...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }), ...(dto.paymentSystem == null ? {} : { paymentSystem: dto.paymentSystem }),
name: name(dto.name, MAX_CARD_NAME_LENGTH), name: name(dto.name, MAX_CARD_NAME_LENGTH),
@@ -120,8 +127,9 @@ export function operationInput(dto: CreateBankOperationDto) {
dto.description != null && dto.description != null &&
(typeof dto.description !== 'string' || (typeof dto.description !== 'string' ||
dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH) dto.description.length > MAX_OPERATION_DESCRIPTION_LENGTH)
) ) {
fail(); fail();
}
return { return {
kind: dto.kind, kind: dto.kind,
cardId: dto.cardId, cardId: dto.cardId,
@@ -1,24 +1,27 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankCard, Prisma } from '../../../../../generated/prisma/client'; import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; 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 { import {
BankCardDto, BankCardDto,
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money';
import { import {
cardInput,
BankErrorCode, BankErrorCode,
cardInput,
conflict, conflict,
currentDate, currentDate,
fail, fail,
fingerprint, fingerprint,
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
@@ -26,6 +29,7 @@ export class BankCardsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where: { where: {
@@ -47,6 +51,7 @@ export class BankCardsService {
archivedAt: card.archivedAt?.toISOString() ?? null, archivedAt: card.archivedAt?.toISOString() ?? null,
}; };
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
Promise.all( Promise.all(
@@ -59,11 +64,13 @@ export class BankCardsService {
), ),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
createCard(userId: string, dto: CreateBankCardDto) { createCard(userId: string, dto: CreateBankCardDto) {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
@@ -71,8 +78,10 @@ export class BankCardsService {
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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); conflict(BankErrorCode.IntentConflict);
}
return this.cardView(tx, existing); return this.cardView(tx, existing);
} }
currentDate(input.openingDate); currentDate(input.openingDate);
@@ -85,6 +94,7 @@ export class BankCardsService {
); );
}); });
} }
updateCard(userId: string, id: string, dto: UpdateBankCardDto) { updateCard(userId: string, id: string, dto: UpdateBankCardDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const previous = await this.repository.card(tx, userId, id); const previous = await this.repository.card(tx, userId, id);
@@ -93,13 +103,9 @@ export class BankCardsService {
id, id,
name: dto.name ?? previous.name, name: dto.name ?? previous.name,
bankName: bankName:
dto.bankName === undefined dto.bankName === undefined ? previous.bankName : dto.bankName,
? (previous.bankName ?? undefined)
: (dto.bankName ?? undefined),
lastFour: lastFour:
dto.lastFour === undefined dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
? (previous.lastFour ?? undefined)
: (dto.lastFour ?? undefined),
paymentSystem: paymentSystem:
dto.paymentSystem === undefined dto.paymentSystem === undefined
? previous.paymentSystem ? previous.paymentSystem
@@ -111,8 +117,9 @@ export class BankCardsService {
const financial = const financial =
input.openingBalanceKopecks !== previous.openingBalanceKopecks || input.openingBalanceKopecks !== previous.openingBalanceKopecks ||
input.openingDate !== previous.openingDate; input.openingDate !== previous.openingDate;
if (financial && previous.archivedAt) if (financial && previous.archivedAt) {
conflict(BankErrorCode.CardArchived); conflict(BankErrorCode.CardArchived);
}
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await tx.bankCardOperation.findFirst({ await tx.bankCardOperation.findFirst({
@@ -123,8 +130,9 @@ export class BankCardsService {
OR: [{ cardId: id }, { targetCardId: id }], OR: [{ cardId: id }, { targetCardId: id }],
}, },
}) })
) ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
}
const changed = await tx.bankCard.updateMany({ const changed = await tx.bankCard.updateMany({
where: { id, userId, version: dto.version }, where: { id, userId, version: dto.version },
data: { 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)); 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 { GoneException, Injectable } from '@nestjs/common';
import { import {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service'; 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 { import {
BankOperationDto, BankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
BankOperationsQueryDto, BankOperationsQueryDto,
BankPageQueryDto, BankPageQueryDto,
CreateBankOperationDto, CreateBankOperationDto,
DEFAULT_BANK_PAGE_SIZE,
UpdateBankOperationDto, UpdateBankOperationDto,
} from '../presentation/bank-card.dto'; } from '../presentation/bank-card.dto';
import { rubMoney } from '../domain/rub-money';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
@@ -25,7 +27,9 @@ import {
operationInput, operationInput,
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
@@ -33,6 +37,7 @@ export class BankOperationsService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
row: BankCardOperation, row: BankCardOperation,
@@ -76,7 +81,7 @@ export class BankOperationsService {
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source
? rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n), refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) )
: null, : null,
date: row.date, date: row.date,
@@ -85,6 +90,7 @@ export class BankOperationsService {
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
} }
private async validateOperation( private async validateOperation(
tx: Tx, tx: Tx,
userId: string, userId: string,
@@ -102,23 +108,26 @@ export class BankOperationsService {
if ( if (
(id === input.cardId || id === input.targetCardId) && (id === input.cardId || id === input.targetCardId) &&
input.date < card.openingDate input.date < card.openingDate
) ) {
fail(BankErrorCode.InvalidDate); fail(BankErrorCode.InvalidDate);
}
} }
if ( if (
old && old &&
(old.kind !== input.kind || (old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId) old.originalExpenseId !== input.originalExpenseId)
) ) {
fail(BankErrorCode.ImmutableOperation); fail(BankErrorCode.ImmutableOperation);
}
if (input.kind === BankOperationKind.Transfer) { if (input.kind === BankOperationKind.Transfer) {
if ( if (
!input.targetCardId || !input.targetCardId ||
input.targetCardId === input.cardId || input.targetCardId === input.cardId ||
input.categoryId || input.categoryId ||
input.originalExpenseId input.originalExpenseId
) ) {
fail(); fail();
}
} else if (input.targetCardId) fail(); } else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail(); if (input.originalExpenseId) fail();
@@ -133,11 +142,13 @@ export class BankOperationsService {
!(await tx.expenseCategory.findFirst({ !(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId }, where: { userId, id: input.categoryId },
})) }))
) ) {
missing(); missing();
}
} else if (input.categoryId) fail(); } else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail(); fail();
}
if (input.kind === BankOperationKind.Refund) { if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail(); if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
@@ -149,8 +160,9 @@ export class BankOperationsService {
source.deletedAt || source.deletedAt ||
source.kind !== BankOperationKind.Expense || source.kind !== BankOperationKind.Expense ||
source.cardId !== input.cardId source.cardId !== input.cardId
) ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
}
if ( if (
( (
await tx.bankCardOperation.updateMany({ await tx.bankCardOperation.updateMany({
@@ -162,9 +174,10 @@ export class BankOperationsService {
}, },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
where: { where: {
userId, userId,
@@ -175,11 +188,12 @@ export class BankOperationsService {
}); });
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, 0n) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
input.amountKopecks > input.amountKopecks >
source.amountKopecks source.amountKopecks
) ) {
conflict(BankErrorCode.RefundLimit); conflict(BankErrorCode.RefundLimit);
}
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await tx.bankCardOperation.findMany({ const refunds = await tx.bankCardOperation.findMany({
@@ -189,12 +203,14 @@ export class BankOperationsService {
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
refunds.some((row) => row.date < input.date) || refunds.some((row) => row.date < input.date) ||
refunds.reduce((sum, row) => sum + row.amountKopecks, 0n) > refunds.reduce((sum, row) => sum + row.amountKopecks, ZERO_KOPECKS) >
input.amountKopecks) input.amountKopecks)
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
} }
} }
createOperation(userId: string, dto: CreateBankOperationDto) { createOperation(userId: string, dto: CreateBankOperationDto) {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
@@ -204,10 +220,13 @@ export class BankOperationsService {
}); });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); 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); conflict(BankErrorCode.IntentConflict);
if (existing.deletedAt) }
if (existing.deletedAt) {
throw new GoneException({ code: BankErrorCode.OperationDeleted }); throw new GoneException({ code: BankErrorCode.OperationDeleted });
}
return this.operationView(tx, existing); return this.operationView(tx, existing);
} }
const input = { ...normalized }; const input = { ...normalized };
@@ -220,6 +239,7 @@ export class BankOperationsService {
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
@@ -227,6 +247,7 @@ export class BankOperationsService {
return this.operationView(tx, row); return this.operationView(tx, row);
}); });
} }
updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) { updateOperation(userId: string, id: string, dto: UpdateBankOperationDto) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const old = await this.repository.operation(tx, userId, id); const old = await this.repository.operation(tx, userId, id);
@@ -251,30 +272,34 @@ export class BankOperationsService {
where: { id, userId, version: dto.version, deletedAt: null }, where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } }, data: { ...input, version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
); );
}); });
} }
deleteOperation(userId: string, id: string, version: number) { deleteOperation(userId: string, id: string, version: number) {
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) return; if (row.deletedAt) return;
if (row.version !== version) conflict(); if (row.version !== version) conflict();
await this.repository.card(tx, userId, row.cardId, true); await this.repository.card(tx, userId, row.cardId, true);
if (row.targetCardId) if (row.targetCardId) {
await this.repository.card(tx, userId, row.targetCardId, true); await this.repository.card(tx, userId, row.targetCardId, true);
}
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await tx.bankCardOperation.findFirst({ (await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null }, where: { userId, originalExpenseId: id, deletedAt: null },
})) }))
) ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
}
if (row.originalExpenseId) { if (row.originalExpenseId) {
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
@@ -287,9 +312,10 @@ export class BankOperationsService {
where: { id: source.id, userId, version: source.version }, where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } }, data: { version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
} }
if ( if (
( (
@@ -297,11 +323,13 @@ export class BankOperationsService {
where: { id, userId, version, deletedAt: null }, where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } }, data: { deletedAt: new Date(), version: { increment: 1 } },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_ROWS
) ) {
conflict(); conflict();
}
}); });
} }
history( history(
userId: string, userId: string,
id: string, id: string,
@@ -313,8 +341,8 @@ export class BankOperationsService {
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const page = query.page ?? 1, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE; page = query.page ?? FIRST_PAGE;
const where: Prisma.BankCardOperationWhereInput = { const where: Prisma.BankCardOperationWhereInput = {
userId, userId,
deletedAt: null, deletedAt: null,
@@ -327,7 +355,7 @@ export class BankOperationsService {
const rows = await tx.bankCardOperation.findMany({ const rows = await tx.bankCardOperation.findMany({
where, where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - 1) * limit, skip: (page - FIRST_PAGE) * limit,
take: limit, take: limit,
}); });
return { return {
@@ -1,16 +1,21 @@
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; 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 { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor( constructor(
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository, private readonly repository: BankCardsRepository,
) {} ) {}
async categories(userId: string) { async categories(userId: string) {
return ( return (
await this.prisma.expenseCategory.findMany({ await this.prisma.expenseCategory.findMany({
@@ -24,6 +29,7 @@ export class ExpenseCategoriesService {
version, version,
})); }));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -44,6 +50,7 @@ export class ExpenseCategoriesService {
}; };
}); });
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
@@ -59,14 +66,15 @@ export class ExpenseCategoriesService {
version: { increment: 1 }, version: { increment: 1 },
}, },
}) })
).count !== 1 ).count !== EXPECTED_UPDATED_CATEGORIES
) ) {
conflict(); conflict();
}
return { return {
id, id,
name: label, name: label,
isDefault: row.seedKey === 'other', isDefault: row.seedKey === 'other',
version: version + 1, version: version + VERSION_INCREMENT,
}; };
}); });
} }
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { bankSummary } from '../domain/bank-card.calculations'; import { bankSummary } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { periodBounds } from './bank-card-input';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { BankSummaryQueryDto } from '../presentation/bank-card.dto';
import { periodBounds } from './bank-card-input';
@Injectable() @Injectable()
export class GetBankSummaryUseCase { export class GetBankSummaryUseCase {
constructor(private readonly repository: BankCardsRepository) {} constructor(private readonly repository: BankCardsRepository) {}
@@ -1,11 +1,12 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { AuthModule } from '../../auth/auth.module'; 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 { BankCardsService } from './application/bank-cards.service';
import { BankOperationsService } from './application/bank-operations.service'; import { BankOperationsService } from './application/bank-operations.service';
import { ExpenseCategoriesService } from './application/expense-categories.service'; import { ExpenseCategoriesService } from './application/expense-categories.service';
import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './application/get-bank-summary.use-case';
import { BankCardsRepository } from './infrastructure/bank-cards.repository';
import { BankCardsController } from './presentation/bank-cards.controller';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule],
controllers: [BankCardsController], 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_CARD_NAME_LENGTH = 100;
export const MAX_CATEGORY_NAME_LENGTH = 80; export const MAX_CATEGORY_NAME_LENGTH = 80;
export const MAX_OPERATION_DESCRIPTION_LENGTH = 500; export const MAX_OPERATION_DESCRIPTION_LENGTH = 500;
@@ -116,7 +116,9 @@ describe('bank card calculations', () => {
amountKopecks: 5000n, amountKopecks: 5000n,
}, },
]; ];
expect(bankSummary('2026-09', operations)).toEqual({ expect(
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, operations),
).toEqual({
expenses: 120n, expenses: 120n,
refunds: 500n, refunds: 500n,
net: -380n, net: -380n,
@@ -125,7 +127,9 @@ describe('bank card calculations', () => {
{ categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n }, { categoryId: 'travel', expenses: 20n, refunds: 0n, net: 20n },
], ],
}); });
expect(bankSummary('2026-07', operations)).toEqual({ expect(
bankSummary({ from: '2026-07-01', to: '2026-07-31' }, operations),
).toEqual({
expenses: 0n, expenses: 0n,
refunds: 0n, refunds: 0n,
net: 0n, net: 0n,
@@ -141,7 +145,12 @@ describe('bank card calculations', () => {
amountKopecks: 2n ** 63n - 1n, amountKopecks: 2n ** 63n - 1n,
}; };
const total = 2n * (2n ** 63n - 1n); const total = 2n * (2n ** 63n - 1n);
expect(bankSummary('2026-09', [operation, operation])).toEqual({ expect(
bankSummary({ from: '2026-09-01', to: '2026-09-30' }, [
operation,
operation,
]),
).toEqual({
expenses: total, expenses: total,
refunds: 0n, refunds: 0n,
net: total, net: total,
@@ -1,6 +1,7 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money';
const ISO_MONTH_LENGTH = 7;
export function moscowToday(now = new Date()): string { export function moscowToday(now = new Date()): string {
return new Intl.DateTimeFormat('en-CA', { return new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Moscow', timeZone: 'Europe/Moscow',
@@ -11,11 +12,11 @@ export function moscowToday(now = new Date()): string {
} }
export function isCalendarDate(value: string): boolean { 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; if (value.startsWith('0000')) return false;
const date = new Date(`${value}T00:00:00.000Z`); const date = new Date(`${value}T00:00:00.000Z`);
return ( 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
); );
} }
@@ -23,10 +24,10 @@ export function bankBalance(
cardId: string, cardId: string,
opening: bigint, opening: bigint,
operations: Array<{ operations: Array<{
kind: string;
cardId: string;
targetCardId: string | null;
amountKopecks: bigint; amountKopecks: bigint;
cardId: string;
kind: string;
targetCardId: null | string;
}>, }>,
): bigint { ): bigint {
// ponytail: O(N) history fold; optimize only after measuring growing histories. // ponytail: O(N) history fold; optimize only after measuring growing histories.
@@ -36,67 +37,65 @@ export function bankBalance(
if ( if (
operation.kind === BankOperationKind.Expense || operation.kind === BankOperationKind.Expense ||
operation.kind === BankOperationKind.Transfer operation.kind === BankOperationKind.Transfer
) ) {
balance -= operation.amountKopecks; balance -= operation.amountKopecks;
else if ( } else if (
operation.kind === BankOperationKind.Topup || operation.kind === BankOperationKind.Topup ||
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
if ( if (
operation.kind === BankOperationKind.Transfer && operation.kind === BankOperationKind.Transfer &&
operation.targetCardId === cardId operation.targetCardId === cardId
) ) {
balance += operation.amountKopecks; balance += operation.amountKopecks;
}
} }
return balance; return balance;
} }
export function bankSummary( export function bankSummary(
period: string | { from: string; to: string }, period: { from: string; to: string },
operations: Array<{ operations: Array<{
kind: string;
date: string;
amountKopecks: bigint; amountKopecks: bigint;
categoryId: string | null; categoryId: null | string;
date: string;
kind: string;
}>, }>,
) { ) {
let expenses = 0n; let expenses = ZERO_KOPECKS;
let refunds = 0n; let refunds = ZERO_KOPECKS;
const categories = new Map< const categories = new Map<
string, 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. // ponytail: O(N) history fold; optimize only after measuring growing histories.
for (const operation of operations) { for (const operation of operations) {
if ( if (operation.date < period.from || operation.date > period.to) continue;
typeof period === 'string'
? operation.date.slice(0, ISO_MONTH_LENGTH) !== period
: operation.date < period.from || operation.date > period.to
)
continue;
if ( if (
operation.kind !== BankOperationKind.Expense && operation.kind !== BankOperationKind.Expense &&
operation.kind !== BankOperationKind.Refund operation.kind !== BankOperationKind.Refund
) ) {
continue; continue;
}
const expense = const expense =
operation.kind === BankOperationKind.Expense operation.kind === BankOperationKind.Expense
? operation.amountKopecks ? operation.amountKopecks
: 0n; : ZERO_KOPECKS;
const refund = const refund =
operation.kind === BankOperationKind.Refund operation.kind === BankOperationKind.Refund
? operation.amountKopecks ? operation.amountKopecks
: 0n; : ZERO_KOPECKS;
expenses += expense; expenses += expense;
refunds += refund; refunds += refund;
if (operation.categoryId !== null) { if (operation.categoryId !== null) {
const category = categories.get(operation.categoryId) ?? { const category = categories.get(operation.categoryId) ?? {
categoryId: operation.categoryId, categoryId: operation.categoryId,
expenses: 0n, expenses: ZERO_KOPECKS,
refunds: 0n, refunds: ZERO_KOPECKS,
net: 0n, net: ZERO_KOPECKS,
}; };
category.expenses += expense; category.expenses += expense;
category.refunds += refund; category.refunds += refund;
@@ -39,8 +39,9 @@ describe('RUB money', () => {
}); });
it('requires positive operation amounts, including at the maximum', () => { 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 }, true)).toThrow();
}
expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n); expect(parseRubMoney({ currency: 'RUB', value: '0.01' }, true)).toBe(1n);
expect( expect(
parseRubMoney({ currency: 'RUB', value: '92233720368547758.07' }, true), 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 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 MIN_KOPECKS = -9_223_372_036_854_775_808n;
const MAX_KOPECKS = 9_223_372_036_854_775_807n; 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' || money?.currency !== 'RUB' ||
typeof money.value !== 'string' || typeof money.value !== 'string' ||
money.value !== money.value.trim() money.value !== money.value.trim()
) ) {
throw new TypeError('Expected RUB money with a decimal string'); throw new TypeError('Expected RUB money with a decimal string');
}
const amount = decimalFromString(money.value); 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'); 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'); 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'); throw new RangeError('Operation amount must be positive');
}
return kopecks; return kopecks;
} }
export function rubMoney(kopecks: bigint): Money { export function rubMoney(kopecks: bigint): Money {
return { return {
currency: 'RUB', 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 { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; 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 { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } 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; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
const TRANSACTION_TIMEOUT_MS = 15_000; const TRANSACTION_TIMEOUT_MS = 15_000;
@@ -28,7 +33,7 @@ const seeds = [
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = 0; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
return await this.prisma.$transaction(run, { return await this.prisma.$transaction(run, {
maxWait: TRANSACTION_MAX_WAIT_MS, maxWait: TRANSACTION_MAX_WAIT_MS,
@@ -40,37 +45,41 @@ export class BankCardsRepository {
? error.code ? error.code
: ''; : '';
const busy = const busy =
code === 'P2034' || isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
code === 'P2028' || isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
continue; continue;
}
if (busy) conflict(BankErrorCode.WriteConflict); if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory); if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
throw error; throw error;
} }
} }
} }
async card(tx: Tx, userId: string, id: string, active = false) { async card(tx: Tx, userId: string, id: string, active = false) {
const card = await tx.bankCard.findFirst({ where: { id, userId } }); const card = await tx.bankCard.findFirst({ where: { id, userId } });
if (!card) return missing(); if (!card) return missing();
if (active && card.archivedAt) conflict(BankErrorCode.CardArchived); if (active && card.archivedAt) conflict(BankErrorCode.CardArchived);
return card; return card;
} }
async operation(tx: Tx, userId: string, id: string) { async operation(tx: Tx, userId: string, id: string) {
const row = await tx.bankCardOperation.findFirst({ where: { id, userId } }); const row = await tx.bankCardOperation.findFirst({ where: { id, userId } });
if (!row) return missing(); if (!row) return missing();
return row; return row;
} }
async seed(tx: Tx, userId: string) { async seed(tx: Tx, userId: string) {
for (const [index, label] of seeds.entries()) { 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 ( if (
!(await tx.expenseCategory.findUnique({ !(await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey } }, where: { userId_seedKey: { userId, seedKey } },
})) }))
) ) {
await tx.expenseCategory.create({ await tx.expenseCategory.create({
data: { data: {
id: randomUUID(), id: randomUUID(),
@@ -80,8 +89,10 @@ export class BankCardsRepository {
nameKey: label.toLocaleLowerCase('ru-RU'), nameKey: label.toLocaleLowerCase('ru-RU'),
}, },
}); });
}
} }
} }
summaryData( summaryData(
userId: string, userId: string,
period: { from: string; to: 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 { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
@@ -28,7 +22,20 @@ import {
ValidateIf, ValidateIf,
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; 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; export const DEFAULT_BANK_PAGE_SIZE = 50;
const MAX_BANK_PAGE_SIZE = 100; const MAX_BANK_PAGE_SIZE = 100;
@@ -41,17 +48,19 @@ export class RubMoneyInputDto {
@ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string; @ApiProperty({ enum: ['RUB'] }) @IsIn(['RUB']) declare currency: string;
@ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' }) @ApiProperty({ pattern: '^-?[0-9]+(\\.[0-9]{1,2})?$' })
@IsString() @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})?$/) @Matches(/^-?\d+(\.\d{1,2})?$/)
declare value: string; declare value: string;
} }
export class CreateBankCardDto { export class CreateBankCardDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @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) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CARD_NAME_LENGTH)
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
declare name: string; declare name: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
type: String, type: String,
nullable: true, nullable: true,
@@ -61,20 +70,24 @@ export class CreateBankCardDto {
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MaxLength(MAX_CARD_NAME_LENGTH) @MaxLength(MAX_CARD_NAME_LENGTH)
bankName?: string | null; bankName?: null | string;
@ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' }) @ApiPropertyOptional({ type: String, nullable: true, pattern: '^[0-9]{4}$' })
@IsOptional() @IsOptional()
@Matches(/^[0-9]{4}$/) @Matches(/^\d{4}$/)
lastFour?: string | null; lastFour?: null | string;
@ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true }) @ApiPropertyOptional({ enum: bankPaymentSystems, nullable: true })
@IsOptional() @IsOptional()
@IsIn(bankPaymentSystems) @IsIn(bankPaymentSystems)
paymentSystem?: string | null; paymentSystem?: null | string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare openingBalance: MoneyDto; declare openingBalance: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
@@ -84,50 +97,56 @@ export class UpdateBankCardDto extends PartialType(
OmitType(CreateBankCardDto, ['id'] as const), OmitType(CreateBankCardDto, ['id'] as const),
{ skipNullProperties: false }, { 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() @ApiPropertyOptional()
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsBoolean() @IsBoolean()
archived?: boolean; archived?: boolean;
} }
export class CategoryNameDto { export class CategoryNameDto {
@ApiProperty({ minLength: 1, maxLength: MAX_CATEGORY_NAME_LENGTH }) @ApiProperty({ minLength: MIN_CATEGORY_NAME_LENGTH, maxLength: MAX_CATEGORY_NAME_LENGTH })
@Transform(trim) @Transform(trim)
@IsString() @IsString()
@MinLength(1) @MinLength(MIN_CATEGORY_NAME_LENGTH)
@MaxLength(MAX_CATEGORY_NAME_LENGTH) @MaxLength(MAX_CATEGORY_NAME_LENGTH)
declare name: string; declare name: string;
} }
export class UpdateExpenseCategoryDto extends CategoryNameDto { 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 { export class CreateBankOperationDto {
@ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare id: string;
@ApiProperty({ enum: bankOperationKinds }) @ApiProperty({ enum: bankOperationKinds })
@IsIn(bankOperationKinds) @IsIn(bankOperationKinds)
declare kind: BankOperationKind; declare kind: BankOperationKind;
@ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string; @ApiProperty({ format: 'uuid' }) @IsUUID() declare cardId: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
targetCardId?: string; targetCardId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
categoryId?: string; categoryId?: string;
@ApiPropertyOptional({ format: 'uuid' }) @ApiPropertyOptional({ format: 'uuid' })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsUUID() @IsUUID()
originalExpenseId?: string; originalExpenseId?: string;
@ApiProperty({ type: RubMoneyInputDto }) @ApiProperty({ type: RubMoneyInputDto })
@IsDefined() @IsDefined()
@ValidateNested() @ValidateNested()
@Type(() => RubMoneyInputDto) @Type(() => RubMoneyInputDto)
declare amount: MoneyDto; declare amount: MoneyDto;
@ApiProperty({ format: 'date' }) @ApiProperty({ format: 'date' })
@IsString() @IsString()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
declare date: string; declare date: string;
@ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH }) @ApiPropertyOptional({ maxLength: MAX_OPERATION_DESCRIPTION_LENGTH })
@ValidateIf((_object, value: unknown) => value !== undefined) @ValidateIf((_object, value: unknown) => value !== undefined)
@IsString() @IsString()
@@ -138,7 +157,7 @@ export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { 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 { export class BankCardsQueryDto {
@ApiPropertyOptional({ enum: ['true', 'false'] }) @ApiPropertyOptional({ enum: ['true', 'false'] })
@@ -147,21 +166,22 @@ export class BankCardsQueryDto {
includeArchived?: string; includeArchived?: string;
} }
export class BankPageQueryDto { export class BankPageQueryDto {
@ApiPropertyOptional({ minimum: 1, default: 1 }) @ApiPropertyOptional({ minimum: FIRST_PAGE, default: FIRST_PAGE })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(FIRST_PAGE)
page?: number; page?: number;
@ApiPropertyOptional({ @ApiPropertyOptional({
minimum: 1, minimum: MIN_PAGE_SIZE,
maximum: MAX_BANK_PAGE_SIZE, maximum: MAX_BANK_PAGE_SIZE,
default: DEFAULT_BANK_PAGE_SIZE, default: DEFAULT_BANK_PAGE_SIZE,
}) })
@IsOptional() @IsOptional()
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_PAGE_SIZE)
@Max(MAX_BANK_PAGE_SIZE) @Max(MAX_BANK_PAGE_SIZE)
limit?: number; limit?: number;
} }
@@ -173,6 +193,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-(0[1-9]|1[0-2])$/) @Matches(/^\d{4}-(0[1-9]|1[0-2])$/)
month?: string; month?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive start date; requires to.', description: 'Inclusive start date; requires to.',
@@ -180,6 +201,7 @@ export class BankPeriodQueryDto {
@IsOptional() @IsOptional()
@Matches(/^\d{4}-\d{2}-\d{2}$/) @Matches(/^\d{4}-\d{2}-\d{2}$/)
from?: string; from?: string;
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: 'Inclusive end date; requires from.', description: 'Inclusive end date; requires from.',
@@ -199,10 +221,10 @@ export class BankSummaryQueryDto extends BankPeriodQueryDto {
cardId?: string; cardId?: string;
} }
export class BankVersionQueryDto { export class BankVersionQueryDto {
@ApiProperty({ minimum: 1 }) @ApiProperty({ minimum: MIN_ENTITY_VERSION })
@Type(() => Number) @Type(() => Number)
@IsInt() @IsInt()
@Min(1) @Min(MIN_ENTITY_VERSION)
declare version: number; declare version: number;
} }
export class ExpenseCategoryDto { export class ExpenseCategoryDto {
@@ -215,16 +237,20 @@ export class BankCardDto {
@ApiProperty({ type: String }) declare id: string; @ApiProperty({ type: String }) declare id: string;
@ApiProperty({ type: String }) declare name: string; @ApiProperty({ type: String }) declare name: string;
@ApiProperty({ type: String, nullable: true }) declare bankName: @ApiProperty({ type: String, nullable: true }) declare bankName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare lastFour: @ApiProperty({ type: String, nullable: true }) declare lastFour:
string | null; null | string;
@ApiProperty({ enum: bankPaymentSystems, nullable: true }) @ApiProperty({ enum: bankPaymentSystems, nullable: true })
declare paymentSystem: string | null; declare paymentSystem: null | string;
@ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare openingBalance: MoneyDto;
@ApiProperty({ type: String }) declare openingDate: string; @ApiProperty({ type: String }) declare openingDate: string;
@ApiProperty({ type: MoneyDto }) declare balance: MoneyDto; @ApiProperty({ type: MoneyDto }) declare balance: MoneyDto;
@ApiProperty({ type: String, nullable: true }) declare archivedAt: @ApiProperty({ type: String, nullable: true }) declare archivedAt:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
} }
export class BankOperationDto { export class BankOperationDto {
@@ -233,27 +259,35 @@ export class BankOperationDto {
@ApiProperty({ type: String }) declare cardId: string; @ApiProperty({ type: String }) declare cardId: string;
@ApiProperty({ type: String }) declare cardName: string; @ApiProperty({ type: String }) declare cardName: string;
@ApiProperty({ type: String, nullable: true }) declare targetCardId: @ApiProperty({ type: String, nullable: true }) declare targetCardId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare targetCardName: @ApiProperty({ type: String, nullable: true }) declare targetCardName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryId: @ApiProperty({ type: String, nullable: true }) declare categoryId:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare categoryName: @ApiProperty({ type: String, nullable: true }) declare categoryName:
string | null; null | string;
@ApiProperty({ type: String, nullable: true }) declare originalExpenseId: @ApiProperty({ type: String, nullable: true }) declare originalExpenseId:
string | null; null | string;
@ApiProperty({ type: MoneyDto }) declare amount: MoneyDto; @ApiProperty({ type: MoneyDto }) declare amount: MoneyDto;
@ApiProperty({ type: MoneyDto, nullable: true }) @ApiProperty({ type: MoneyDto, nullable: true })
declare availableRefund: MoneyDto | null; declare availableRefund: MoneyDto | null;
@ApiProperty({ type: String }) declare date: string; @ApiProperty({ type: String }) declare date: string;
@ApiProperty({ type: String, nullable: true }) declare description: @ApiProperty({ type: String, nullable: true }) declare description:
string | null; null | string;
@ApiProperty({ type: Number }) declare version: number; @ApiProperty({ type: Number }) declare version: number;
@ApiProperty({ type: String }) declare createdAt: string; @ApiProperty({ type: String }) declare createdAt: string;
} }
export class BankOperationsPageDto { export class BankOperationsPageDto {
@ApiProperty({ type: BankOperationDto, isArray: true }) @ApiProperty({ type: BankOperationDto, isArray: true })
declare items: BankOperationDto[]; declare items: BankOperationDto[];
@ApiProperty({ type: Number }) declare page: number; @ApiProperty({ type: Number }) declare page: number;
@ApiProperty({ type: Number }) declare total: number; @ApiProperty({ type: Number }) declare total: number;
@ApiProperty({ type: Boolean }) declare hasNext: boolean; @ApiProperty({ type: Boolean }) declare hasNext: boolean;
@@ -269,7 +303,7 @@ export class BankSummaryDto {
@ApiPropertyOptional({ type: String }) month?: string; @ApiPropertyOptional({ type: String }) month?: string;
@ApiPropertyOptional({ format: 'date' }) from?: string; @ApiPropertyOptional({ format: 'date' }) from?: string;
@ApiPropertyOptional({ format: 'date' }) to?: 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 expenses: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto; @ApiProperty({ type: MoneyDto }) declare refunds: MoneyDto;
@ApiProperty({ type: MoneyDto }) declare net: MoneyDto; @ApiProperty({ type: MoneyDto }) declare net: MoneyDto;
@@ -1,3 +1,5 @@
import type { Request } from 'express';
import { import {
Body, Body,
Controller, Controller,
@@ -21,12 +23,16 @@ import {
ApiTags, ApiTags,
getSchemaPath, getSchemaPath,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import type { Request } from 'express';
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator'; } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; 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 { import {
BankCardDto, BankCardDto,
BankCardsQueryDto, BankCardsQueryDto,
@@ -45,10 +51,6 @@ import {
UpdateBankOperationDto, UpdateBankOperationDto,
UpdateExpenseCategoryDto, UpdateExpenseCategoryDto,
} from './bank-card.dto'; } 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 }; type AuthRequest = Request & { user: AccessPayload };
@ApiResponse({ @ApiResponse({
status: 409, status: 409,
@@ -82,26 +84,31 @@ export class BankCardsController {
private readonly expenseCategories: ExpenseCategoriesService, private readonly expenseCategories: ExpenseCategoriesService,
private readonly bankSummary: GetBankSummaryUseCase, private readonly bankSummary: GetBankSummaryUseCase,
) {} ) {}
@Get() @Get()
@ApiOkResponse({ type: BankCardDto, isArray: true }) @ApiOkResponse({ type: BankCardDto, isArray: true })
list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) { list(@Req() req: AuthRequest, @Query() query: BankCardsQueryDto) {
return this.cards.list(req.user.sub, query.includeArchived === 'true'); return this.cards.list(req.user.sub, query.includeArchived === 'true');
} }
@Post() @Post()
@ApiCreatedResponse({ type: BankCardDto }) @ApiCreatedResponse({ type: BankCardDto })
create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) { create(@Req() req: AuthRequest, @Body() body: CreateBankCardDto) {
return this.cards.createCard(req.user.sub, body); return this.cards.createCard(req.user.sub, body);
} }
@Get('categories') @Get('categories')
@ApiOkResponse({ type: ExpenseCategoryDto, isArray: true }) @ApiOkResponse({ type: ExpenseCategoryDto, isArray: true })
categories(@Req() req: AuthRequest) { categories(@Req() req: AuthRequest) {
return this.expenseCategories.categories(req.user.sub); return this.expenseCategories.categories(req.user.sub);
} }
@Post('categories') @Post('categories')
@ApiCreatedResponse({ type: ExpenseCategoryDto }) @ApiCreatedResponse({ type: ExpenseCategoryDto })
createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) { createCategory(@Req() req: AuthRequest, @Body() body: CategoryNameDto) {
return this.expenseCategories.createCategory(req.user.sub, body.name); return this.expenseCategories.createCategory(req.user.sub, body.name);
} }
@Patch('categories/:id') @Patch('categories/:id')
@ApiOkResponse({ type: ExpenseCategoryDto }) @ApiOkResponse({ type: ExpenseCategoryDto })
updateCategory( updateCategory(
@@ -116,11 +123,13 @@ export class BankCardsController {
body.version, body.version,
); );
} }
@Get('summary') @Get('summary')
@ApiOkResponse({ type: BankSummaryDto }) @ApiOkResponse({ type: BankSummaryDto })
summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) { summary(@Req() req: AuthRequest, @Query() query: BankSummaryQueryDto) {
return this.bankSummary.execute(req.user.sub, query); return this.bankSummary.execute(req.user.sub, query);
} }
@Post('operations') @Post('operations')
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
createOperation( createOperation(
@@ -129,11 +138,13 @@ export class BankCardsController {
) { ) {
return this.operations.createOperation(req.user.sub, body); return this.operations.createOperation(req.user.sub, body);
} }
@Get('operations/:id') @Get('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
operation(@Req() req: AuthRequest, @Param('id') id: string) { operation(@Req() req: AuthRequest, @Param('id') id: string) {
return this.operations.getOperation(req.user.sub, id); return this.operations.getOperation(req.user.sub, id);
} }
@Patch('operations/:id') @Patch('operations/:id')
@ApiOkResponse({ type: BankOperationDto }) @ApiOkResponse({ type: BankOperationDto })
updateOperation( updateOperation(
@@ -143,6 +154,7 @@ export class BankCardsController {
) { ) {
return this.operations.updateOperation(req.user.sub, id, body); return this.operations.updateOperation(req.user.sub, id, body);
} }
@Delete('operations/:id') @Delete('operations/:id')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ApiNoContentResponse() @ApiNoContentResponse()
@@ -153,6 +165,7 @@ export class BankCardsController {
) { ) {
return this.operations.deleteOperation(req.user.sub, id, query.version); return this.operations.deleteOperation(req.user.sub, id, query.version);
} }
@Get(':id/operations') @Get(':id/operations')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
history( history(
@@ -162,6 +175,7 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query); return this.operations.history(req.user.sub, id, query);
} }
@Get(':id/expenses') @Get(':id/expenses')
@ApiOkResponse({ type: BankOperationsPageDto }) @ApiOkResponse({ type: BankOperationsPageDto })
expenses( expenses(
@@ -171,11 +185,13 @@ export class BankCardsController {
) { ) {
return this.operations.history(req.user.sub, id, query, true); return this.operations.history(req.user.sub, id, query, true);
} }
@Get(':id') @Get(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
get(@Req() req: AuthRequest, @Param('id') id: string) { get(@Req() req: AuthRequest, @Param('id') id: string) {
return this.cards.get(req.user.sub, id); return this.cards.get(req.user.sub, id);
} }
@Patch(':id') @Patch(':id')
@ApiOkResponse({ type: BankCardDto }) @ApiOkResponse({ type: BankCardDto })
update( update(
@@ -1,6 +1,8 @@
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -40,15 +42,26 @@ export class BrokerageAccountDto {
'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND'; 'BROKERAGE' | 'INDIVIDUAL_INVESTMENT' | 'INVEST_BOX' | 'MONEY_MARKET_FUND';
@ApiProperty({ type: String, nullable: true, format: 'date' }) @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 }) @ApiProperty({ type: PortfolioSummaryDto, nullable: true })
portfolioSummary?: PortfolioSummaryDto | null; portfolioSummary?: null | PortfolioSummaryDto;
} }
export class BrokerageAccountsResourceDto { 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 }) @ApiProperty({ type: BrokerageAccountDto, isArray: true, nullable: true })
declare data: BrokerageAccountDto[] | null; declare data: BrokerageAccountDto[] | null;
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1 +0,0 @@
export { BrokerageAccountsService } from './brokerage-accounts.service';

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