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

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

BREAKING CHANGE: remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
2026-10-03 11:38:08 +03:00
ksv741 51f345bdc5 Merge branch 'develop' into main
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m46s
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Failing after 10s
Backend verify / verify (pull_request) Skipped
2026-10-02 08:22:31 +03:00
ksv741 0f27da9fd1 Merge main into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m48s
2026-10-02 08:21:02 +03:00
codex-bot 1be6db65d7 refactor: simplify investment data flow
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 7s
Backend verify / verify (push) Failing after 1m43s
2026-10-02 08:12:02 +03:00
codex-bot 99de4af678 Merge pull request #42 from codex/backend-eslint
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Canceled after 3m5s
2026-10-02 08:05:04 +03:00
ksv741 14a204de0d chore: guard backend responsibility boundaries
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 10m0s
2026-10-01 20:53:01 +03:00
ksv741 a13cf84ead refactor: isolate operation ranges and intents 2026-10-01 20:53:01 +03:00
ksv741 7fe88a12c7 refactor: centralize provider projections 2026-10-01 20:53:00 +03:00
ksv741 190eb90ffe refactor: clarify nullable card metadata 2026-10-01 20:53:00 +03:00
ksv741 fb2e851e7e refactor: share authentication result type 2026-10-01 20:53:00 +03:00
ksv741 15a3d5f331 fix(deps): resolve npm audit advisories
Patch gRPC and the existing multer override.
Refresh vulnerable transitive versions.
Scope patched undici/basic-ftp to release-it; retain the audit gate.
2026-10-01 20:51:32 +03:00
ksv741 bee34290fb chore: synchronize develop before audit fixes 2026-10-01 20:08:37 +03:00
ksv741 6cd9c2dd28 perf: group catalog source reads by identity
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 11m54s
2026-09-28 16:37:01 +03:00
ksv741 b24433092b fix: resolve build imports before linting
Resolve dist imports in runtime scripts against their TypeScript sources.

Keep missing-module checks enabled and cover absent build output in the lint gate.

Verified npm ci, prisma generate and full verify with dist absent at startup.
2026-09-28 16:37:01 +03:00
ksv741 33592c1d5f refactor: enforce enum machine codes
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 16:37:01 +03:00
ksv741 183b7f1efd style: align multiline ternary operators 2026-09-28 16:37:01 +03:00
ksv741 b78c215ff1 refactor: replace ignored numeric literals with constants 2026-09-28 16:37:01 +03:00
ksv741 a970eaaf7a chore: strengthen backend eslint profile 2026-09-28 16:37:01 +03:00
ksv741 e76eb545d7 perf: group catalog source reads by identity
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m48s
2026-09-28 16:23:58 +03:00
ksv741 7d2262fb43 fix: resolve build imports before linting
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 11m25s
Resolve dist imports in runtime scripts against their TypeScript sources.

Keep missing-module checks enabled and cover absent build output in the lint gate.

Verified npm ci, prisma generate and full verify with dist absent at startup.
2026-09-28 15:48:44 +03:00
ksv741 33a5135741 chore: merge develop into eslint branch
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 1m54s
2026-09-28 15:19:14 +03:00
ksv741 53b2794963 ci: reduce duplicate actions and serialize checks
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Successful in 10m28s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 3m32s
2026-09-28 14:12:38 +03:00
ksv741 2d14081d27 refactor: enforce enum machine codes
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s
Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
2026-09-28 13:18:06 +03:00
ksv741 0303e60b64 style: align multiline ternary operators
Backend security / security (push) Successful in 8s
Backend verify / verify (push) Failing after 2m11s
Backend security / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 2m17s
Conventional commits / commits (pull_request) Successful in 34s
2026-09-28 12:33:56 +03:00
ksv741 92f216d520 chore(release): 1.0.0
Backend security / security (push) Successful in 8s
Backend image / publish (push) Successful in 12m35s
Conventional commits / commits (pull_request) Successful in 29s
Backend security / security (pull_request) Successful in 7s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / verify (push) Successful in 9m56s
2026-09-26 22:33:48 +03:00
124 changed files with 2385 additions and 2589 deletions
+17 -8
View File
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
request context и ограничивает поля событий. Jest unit tests, изолированные
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
модулями providers, функции и типы. Внешний production import идёт через этот
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и
API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
публичные входы для protobuf conversion и interval rules: импорт расчётов
не загружает DI/Prisma.
публичные входы для protobuf conversion, protocol/error vocabulary и interval
rules: импорт этих значений и расчётов не загружает DI/Prisma.
Внутри модуля используй прямые локальные импорты.
Tests могут импортировать внутренний owner для проверки поведения и mocks.
Новый export требует текущего потребителя; controller и persistence остаются
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
Исключения для индексов, default values, enum/type declarations и readonly
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
semantic magic strings проверяются conventions/review без нового инструмента.
Machine codes и protocol values задаются строковыми enum у владельца.
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
## Logging
@@ -261,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
## Проверка
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
controllers делегируют application, use cases не импортируют Prisma. Чистые
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
+2 -1
View File
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
## Domain values
Finite business vocabulary принадлежит одному domain/module/entity owner.
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
Переиспользуй его symbols; для machine codes и protocol values используй
строковые `enum`, для структуры с разными payload — discriminated union.
Сохраняй wire values; generated/external enums не переписывай вручную.
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
обычные labels не выноси в constants без semantic benefit.
+7 -1
View File
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
Преобразование provider records происходит на boundary: например,
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
остаётся отдельным чистым входом integration. Domain получает внутренние
остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
не должны загружать DI/Prisma через service exports. Domain получает внутренние
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
client или mapper для простого копирования полей. Даты/units/nano разбираются
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
@@ -41,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе.
+3 -1
View File
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
-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
+64 -1
View File
@@ -2,11 +2,74 @@ name: Backend verify
on:
push:
branches: ['**']
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '47 5 * * *'
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
concurrency:
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
cancel-in-progress: true
jobs:
commits:
if: gitea.event_name == 'pull_request'
timeout-minutes: 15
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
with:
ref: ${{ github.event.pull_request.head.sha }}
fetch-depth: 0
- uses: https://github.com/actions/setup-node@v4
with:
node-version-file: .nvmrc
- run: npm ci --ignore-scripts
- name: Check PR title and commits
env:
PR_TITLE: ${{ github.event.pull_request.title }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
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
verify:
needs: [commits, security]
if: >-
!cancelled() && needs.security.result == 'success' &&
gitea.event_name != 'schedule'
timeout-minutes: 45
runs-on: ubuntu-latest
steps:
- uses: https://github.com/actions/checkout@v4
+71
View File
@@ -1,5 +1,76 @@
# Changelog
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
### Bug Fixes
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
### Performance
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
### Refactoring
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
### CI
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
### Tests
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
### Styles
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
### Maintenance
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES
* investment reads return data/meta resource envelopes and locally
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
synchronization endpoints.
### Features
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
### Tests
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
### Bug Fixes
+36 -8
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
выполните обе команды.
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -74,17 +77,38 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
### CI и обновления зависимостей
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci`, генерацию Prisma client,
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями, блокирует любой runtime advisory через
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются.
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
workflow в статус. При внедрении администратор должен заменить required checks
для защищённых веток на `Backend verify / commits (pull_request)`,
`Backend verify / security (pull_request)` и
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
если они требовались. Все три PR-проверки должны оставаться обязательными.
Публикация образов по тегам остаётся в `image.yml`.
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
runners. Конфигурация runner управляется вне этого репозитория.
Renovate группирует minor/patch updates, а major требует согласования в
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
@@ -133,6 +157,10 @@ lint. Он не защищает от усложнения уже учтённо
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
Рост baseline — изменение quality gate: он требует отдельного обоснования
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
## SonarQube
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
+2 -7
View File
@@ -4,11 +4,6 @@
"count": 1
}
},
"src/common/logging/request-logging.middleware.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
@@ -54,7 +49,7 @@
},
"src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 4
"count": 3
},
"sonarjs/no-nested-conditional": {
"count": 4
@@ -169,7 +164,7 @@
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 3
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
+109 -1
View File
@@ -15,6 +15,9 @@ import globals from 'globals';
import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
];
export default defineConfig(
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
@@ -48,7 +51,11 @@ export default defineConfig(
'perfectionist/sort-classes': 'off',
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
'@stylistic/arrow-parens': ['error', 'always'],
'@stylistic/operator-linebreak': ['error', 'after'],
'@stylistic/operator-linebreak': [
'error',
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
},
},
@@ -56,6 +63,18 @@ export default defineConfig(
files: ['**/*.mjs'],
extends: [node.configs['flat/recommended-module']],
},
{
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
rules: {
// These scripts run after build; lint validates their source paths before dist exists.
'n/no-missing-require': ['error', {
resolverConfig: {
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
extensionAlias: { '.js': ['.ts', '.js'] },
},
}],
},
},
{
files: ['**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
@@ -103,4 +122,93 @@ export default defineConfig(
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
},
},
{
files: ['src/modules/**/domain/**/*.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: [
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
'**/*.client', '**/application/**', '**/infrastructure/**',
],
message: 'Domain uses pure rules and value types, not transport or persistence.',
}],
}],
},
},
{
files: ['src/**/*.controller.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: [...prismaImports, '**/*.repository', '**/*.client'],
message: 'Controllers delegate to application services or use cases.',
},
{
group: ['**'],
importNamePattern: '(?:Client|Repository|PrismaService)$',
message: 'Use application orchestration rather than data providers in controllers.',
},
],
}],
},
},
{
files: ['src/**/*.use-case.ts'],
ignores: tests,
rules: {
'no-restricted-imports': ['error', {
patterns: [{
group: prismaImports,
message: 'Use cases obtain data through concrete repositories.',
}],
}],
},
},
{
files: [
'src/**/*.ts',
],
ignores: [
'**/*.spec.ts',
'test/**/*.ts',
],
rules: {
'no-restricted-syntax': [
'error',
{
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
message: 'Use the owning enum member for machine codes and protocol values.',
},
{
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
message: 'Use the owning enum member instead of a template machine code.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
message: 'Use an enum member instead of an inline code or protocol value.',
},
{
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
message: 'Use an enum member instead of constructing a code or protocol value.',
},
{
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
message: 'Use a computed enum member as the protocol mapping key.',
},
{
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
message: 'Compare codes with their enum members, not string literals.',
},
{
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
},
],
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
},
);
+61 -226
View File
@@ -1,15 +1,15 @@
{
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"license": "UNLICENSED",
"dependencies": {
"@grpc/grpc-js": "1.14.4",
"@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0",
@@ -1425,29 +1425,6 @@
"node": "^20.19.0 || ^22.13.0 || >=24"
}
},
"node_modules/@eslint/config-array/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -1536,9 +1513,9 @@
}
},
"node_modules/@grpc/grpc-js": {
"version": "1.14.4",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
"version": "1.14.5",
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
"license": "Apache-2.0",
"dependencies": {
"@grpc/proto-loader": "^0.8.0",
@@ -2186,6 +2163,13 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -2797,29 +2781,6 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/@nestjs/cli/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@nestjs/cli/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@nestjs/cli/node_modules/chokidar": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
@@ -5095,29 +5056,6 @@
"typescript": ">=4.8.4 <6.1.0"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -6270,6 +6208,16 @@
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
}
},
"node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
@@ -6304,9 +6252,9 @@
}
},
"node_modules/basic-ftp": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6415,6 +6363,19 @@
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/browserslist": {
"version": "4.28.8",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
@@ -6506,6 +6467,7 @@
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true,
"license": "MIT"
},
"node_modules/builtin-modules": {
@@ -7019,6 +6981,7 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"dev": true,
"engines": [
"node >= 6.0"
],
@@ -8358,29 +8321,6 @@
}
}
},
"node_modules/eslint-plugin-import-x/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-import-x/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -8587,29 +8527,6 @@
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -8658,29 +8575,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/eslint/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
@@ -9053,9 +8947,9 @@
}
},
"node_modules/fast-uri": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
"version": "3.1.8",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
"funding": [
{
"type": "github",
@@ -9537,29 +9431,6 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
@@ -9967,9 +9838,9 @@
}
},
"node_modules/ip-address": {
"version": "10.7.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"version": "10.7.2",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
"dev": true,
"license": "MIT",
"engines": {
@@ -11641,14 +11512,13 @@
"license": "MIT"
},
"node_modules/multer": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
@@ -12829,9 +12699,9 @@
"license": "MIT"
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
@@ -12839,6 +12709,10 @@
},
"engines": {
"node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-agent": {
@@ -13213,16 +13087,6 @@
}
}
},
"node_modules/release-it/node_modules/undici": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/remeda": {
"version": "2.33.4",
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
@@ -13803,13 +13667,6 @@
"node": ">= 10.x"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/sql-escaper": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
@@ -14181,29 +14038,6 @@
"node": ">=18"
}
},
"node_modules/test-exclude/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/test-exclude/node_modules/brace-expansion": {
"version": "5.0.9",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/test-exclude/node_modules/glob": {
"version": "10.5.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
@@ -14234,9 +14068,9 @@
"license": "MIT"
},
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
"version": "2.1.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -14633,6 +14467,7 @@
"version": "0.0.6",
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"dev": true,
"license": "MIT"
},
"node_modules/typescript": {
+9 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wealth-ledger-backend",
"version": "0.2.3",
"version": "2.0.0",
"description": "",
"author": "",
"private": true,
@@ -26,6 +26,7 @@
"test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs",
@@ -37,7 +38,7 @@
"test:lint": "node --test test/eslint-config.cjs"
},
"dependencies": {
"@grpc/grpc-js": "1.14.4",
"@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.0",
@@ -104,12 +105,17 @@
"typescript-eslint": "8.70.1"
},
"overrides": {
"proxy-addr": "2.0.8",
"@nestjs/platform-express": {
"multer": "2.3.0"
"multer": "2.4.0"
},
"@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2"
},
"release-it": {
"basic-ftp": "6.2.1",
"undici": "7.29.1"
},
"prisma": {
"mysql2": "3.24.4"
}
-1
View File
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
+2 -36
View File
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { ConfigModule } from '@nestjs/config';
import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation';
import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true,
validate: validateEnv,
}),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule,
PrismaModule,
AuthModule,
@@ -7,11 +7,13 @@ import {
getSchemaPath,
} from '@nestjs/swagger';
import { HttpErrorCode } from '../error-code';
export class ProblemDetailsDto {
@ApiProperty({ example: 401 })
declare status: number;
@ApiProperty({ example: 'UNAUTHORIZED' })
@ApiProperty({ example: HttpErrorCode.Unauthorized })
declare code: string;
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
+8
View File
@@ -0,0 +1,8 @@
export enum HttpErrorCode {
BadRequest = 'BAD_REQUEST',
InternalServerError = 'INTERNAL_SERVER_ERROR',
NotFound = 'NOT_FOUND',
Unauthorized = 'UNAUTHORIZED',
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
ValidationError = 'VALIDATION_ERROR',
}
+18 -16
View File
@@ -9,24 +9,26 @@ import {
ValidationError,
} from '@nestjs/common';
import { HttpErrorCode } from '../error-code';
@Catch()
export class HttpExceptionFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp();
const response = context.getResponse<Response>();
const status =
exception instanceof HttpException ?
exception.getStatus() :
HttpStatus.INTERNAL_SERVER_ERROR;
exception instanceof HttpException
? exception.getStatus()
: HttpStatus.INTERNAL_SERVER_ERROR;
const payload =
exception instanceof HttpException ? exception.getResponse() : undefined;
const isValidation = Array.isArray(
(payload as { message?: unknown })?.message,
);
const code = isValidation ?
'VALIDATION_ERROR' :
this.codeFor(status, payload);
const code = isValidation
? HttpErrorCode.ValidationError
: this.codeFor(status, payload);
response.locals ??= {};
response.locals.publicErrorCode = code;
response
@@ -35,13 +37,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
.json({
status,
code,
...(isValidation ?
{
...(isValidation
? {
errors: this.validationErrors(
(payload as { message: ValidationError[] }).message,
),
} :
{}),
}
: {}),
});
}
@@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
return (
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
{
400: 'BAD_REQUEST',
401: 'UNAUTHORIZED',
404: 'NOT_FOUND',
415: 'UNSUPPORTED_MEDIA_TYPE',
500: 'INTERNAL_SERVER_ERROR',
}[status] ?? 'INTERNAL_SERVER_ERROR'
400: HttpErrorCode.BadRequest,
401: HttpErrorCode.Unauthorized,
404: HttpErrorCode.NotFound,
415: HttpErrorCode.UnsupportedMediaType,
500: HttpErrorCode.InternalServerError,
}[status] ?? HttpErrorCode.InternalServerError
);
}
+59 -9
View File
@@ -1,16 +1,66 @@
import { Global, Module } from '@nestjs/common';
import type { Request, Response } from 'express';
import type { Options } from 'pino-http';
import { RequestContextService } from './request-context.service';
import { RequestLoggingMiddleware } from './request-logging.middleware';
import { Global, Module } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { Environment } from '../../config/env.validation';
import { StructuredLogger } from './structured-logger.service';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global()
@Module({
providers: [
RequestContextService,
RequestLoggingMiddleware,
StructuredLogger,
],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
imports: [LoggerModule.forRootAsync<Request, Response>({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
})],
providers: [StructuredLogger],
exports: [StructuredLogger],
})
export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
};
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
run<T>(context: RequestLogContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestLogContext | undefined {
return this.storage.getStore();
}
set(context: Partial<RequestLogContext>): void {
const current = this.storage.getStore();
if (current) Object.assign(current, context);
}
}
@@ -1,54 +0,0 @@
import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => {
it('returns a server ID and emits one safe completion event', () => {
const requestContext = {
run: jest.fn((_: unknown, next: () => void) => next()),
};
const logger = { event: jest.fn() };
const middleware = new RequestLoggingMiddleware(
requestContext as never,
logger as never,
);
const response = Object.assign(new EventEmitter(), {
statusCode: 200,
locals: {},
setHeader: jest.fn(),
});
const next = jest.fn();
const request: {
baseUrl: string;
header: jest.Mock<string>;
id?: string;
method: string;
route: { path: string };
} = {
method: 'GET',
baseUrl: '/api/v1',
route: { path: '/accounts/:id' },
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
};
middleware.use(request as never, response as never, next);
response.emit('finish');
expect(response.setHeader).toHaveBeenCalledWith(
'X-Request-ID',
expect.stringMatching(/^[0-9a-f-]{36}$/i),
);
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(requestContext.run).toHaveBeenCalledWith(
expect.objectContaining({
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
}),
next,
);
expect(logger.event).toHaveBeenCalledWith(
'info',
'http.request.completed',
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
);
});
});
@@ -1,67 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
constructor(
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
use(request: Request, response: Response, next: NextFunction): void {
const incoming = request.header('x-request-id');
const clientRequestId =
incoming && uuid.test(incoming) ? incoming : undefined;
const existingRequestId = (request as Request & { id?: unknown }).id;
const requestId =
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
(request as unknown as { id: string }).id = requestId;
const startedAt = performance.now();
response.setHeader('X-Request-ID', requestId);
response.on('finish', () => {
const routeRequest: unknown = request;
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string' ?
`${request.baseUrl}${routePath}` :
'<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode ?
{ code: response.locals.publicErrorCode as string } :
{}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
'error' :
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
'warn' :
'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = {
debug: jest.fn(),
info: jest.fn(),
@@ -12,8 +11,7 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.info('event', {
userId: 'user',
@@ -23,7 +21,6 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith(
{
requestId: 'request',
userId: 'user',
brokerageAccountId: 'account',
},
@@ -32,8 +29,7 @@ describe('StructuredLogger', () => {
});
it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined);
const logger = new StructuredLogger(context as never, pino as never);
const logger = new StructuredLogger(pino as never);
logger.event('debug', 'debug event');
logger.event('warn', 'warn event');
@@ -1,8 +1,6 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>;
@@ -27,10 +25,7 @@ const allowedFields = new Set([
@Injectable()
export class StructuredLogger implements OnApplicationShutdown {
constructor(
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
constructor(private readonly pino: PinoLogger) {}
info(message: string, context?: LogContext): void {
this.write('info', message, context);
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }),
this.safeContext(context),
message,
);
}
+3 -3
View File
@@ -105,9 +105,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
JWT_SECRET: jwtSecret,
PORT: parsedPort,
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ?
{ TBANK_CA_CERT_PATH: tbankCaCertPath } :
{}),
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
? { TBANK_CA_CERT_PATH: tbankCaCertPath }
: {}),
TBANK_GRPC_ENDPOINT: tbankEndpoint,
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
+7 -5
View File
@@ -1,6 +1,8 @@
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;
@@ -152,7 +154,7 @@ export class MoexClient {
pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start)
) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
if (index + pageSize >= total) return rows;
start = index + pageSize;
@@ -172,22 +174,22 @@ export class MoexClient {
}
return body as IssResponse;
} catch {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
}
}
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
const columns = block.columns;
if (!columns.every((column) => typeof column === 'string')) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return block.data.map((values) => {
if (!Array.isArray(values) || values.length !== columns.length) {
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
}
return Object.fromEntries(
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
+3 -1
View File
@@ -4,6 +4,8 @@ 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;
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
if (this.cache.get('registry') === pending) {
this.cache.set('registry', pending, { ttl: 60_000 });
}
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
});
this.cache.set('registry', pending);
}
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0;
@@ -43,12 +44,12 @@ export class InstrumentMetadataRepository {
async readMany(identities: MetadataIdentity[]) {
const identifiers = identities.flatMap((identity) => [
...(identity.instrumentUid?.trim() ?
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
[]),
...(identity.figi?.trim() ?
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
[]),
...(identity.instrumentUid?.trim()
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
: []),
...(identity.figi?.trim()
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
: []),
]);
if (!identifiers.length) {
return new Map<string, ReturnType<typeof selectMetadata>>();
@@ -233,9 +234,9 @@ export class InstrumentMetadataRepository {
update: {
...data,
dataRevision: {
increment: isDeepStrictEqual(previous?.metadata, payload) ?
UNCHANGED_REVISION_INCREMENT :
REVISION_INCREMENT,
increment: isDeepStrictEqual(previous?.metadata, payload)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -256,9 +257,9 @@ export class InstrumentMetadataRepository {
matchedUids,
fetchedAt: now,
dataRevision: {
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
UNCHANGED_REVISION_INCREMENT :
REVISION_INCREMENT,
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
},
data: {
state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
if (success) {
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
state: 'running',
connection: { invalidToken: true },
},
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
}
}
@@ -12,6 +12,7 @@ import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient, type TbankInstrument } from './tbank.client';
export { metadataKey } from './instrument-metadata.repository';
export type { MetadataIdentity } from './instrument-metadata.repository';
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
if (
!identity.figi ||
(error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN')
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
) {
throw error;
}
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'instrument-metadata.failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
}
}
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
}
} catch {
this.logger.event('error', 'instrument-metadata.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
this.active.clear();
this.producers.clear();
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { TbankTokenService } from './tbank-token.service';
@@ -103,7 +104,7 @@ export class TbankAccessService {
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
});
return result.count === EXPECTED_UPDATED_CONNECTIONS;
}
@@ -205,7 +206,7 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => {
const claimed = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
if (!claimed.count) return false;
await tx.tbankConnection.update({
@@ -236,10 +237,10 @@ export class TbankAccessService {
},
});
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' });
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
if (
connection.membershipAt &&
@@ -1,14 +1,15 @@
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
import { TbankAccountType } from './tbank-protocol';
import { TbankAccount } from './tbank.client';
const accountTypes = {
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
ACCOUNT_TYPE_TINKOFF_IIS: {
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
[TbankAccountType.TinkoffIis]: {
type: 'INDIVIDUAL_INVESTMENT',
name: 'ИИС',
},
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
ACCOUNT_TYPE_INVEST_FUND: {
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
[TbankAccountType.InvestFund]: {
type: 'MONEY_MARKET_FUND',
name: 'Фонд денежного рынка',
},
@@ -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;
}
@@ -6,6 +6,7 @@ import {
TbankAccessService,
type TbankSyncAttempt,
} from './tbank-access.service';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankClient } from './tbank.client';
@Injectable()
@@ -37,7 +38,7 @@ export class TbankMembershipService {
.catch(() =>
this.logger.event('error', 'tbank.membership.persistence_failed', {
userId,
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
}),
)
.finally(() => this.active.delete(attemptId));
@@ -62,7 +63,7 @@ export class TbankMembershipService {
} catch (error) {
await this.access.failMembership(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
);
}
}
@@ -0,0 +1,42 @@
import { loadSync } from '@grpc/proto-loader';
import { join } from 'node:path';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import {
InstrumentIdType,
isTbankProtocolValue,
TbankAccountStatus,
TbankAccountType,
TbankCandleInterval,
TbankCouponType,
TbankInstrumentType,
TbankOperationState,
TbankOperationType,
} from './tbank-protocol';
it('uses the string enum values declared by the vendored protocol', () => {
const directory = join(__dirname, 'proto/contracts');
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
for (const [name, values] of Object.entries({
InstrumentIdType,
InstrumentType: TbankInstrumentType,
AccountStatus: TbankAccountStatus,
AccountType: TbankAccountType,
CandleInterval: TbankCandleInterval,
CouponType: TbankCouponType,
OperationState: TbankOperationState,
OperationType: TbankOperationType,
})) {
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
const descriptor = definition as { type: { value: { name: string }[] } };
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
}
});
it('recognizes supported wire values without accepting unknown ones', () => {
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
});
+70
View File
@@ -0,0 +1,70 @@
// 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 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;
}
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Environment } from '../../config/env.validation';
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
import { TbankCrypto } from './tbank.crypto';
@Injectable()
@@ -26,8 +27,8 @@ export class TbankTokenService {
try {
return await operation();
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
throw error;
}
+14 -49
View File
@@ -1,6 +1,7 @@
import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client';
describe('TbankClient operations', () => {
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
to: { seconds: '2' },
cursor: 'cursor',
limit: 10,
state: 'OPERATION_STATE_EXECUTED' as const,
state: TbankOperationState.Executed,
};
it('passes a cursor operations request to OperationsService', async () => {
@@ -175,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
'recovers metadata after one retry for transient gRPC status %s',
async (code) => {
requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999);
await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
},
);
it('rechecks a full queue before admitting a metadata retry', async () => {
it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50;
requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi');
await jest.advanceTimersByTimeAsync(1999);
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
expect(queue.run).not.toHaveBeenCalled();
queue.size = 49;
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
expect(jest.getTimerCount()).toBe(0);
});
it('does not bypass queue admission after an upstream failure', async () => {
@@ -222,42 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid');
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50;
await jest.advanceTimersByTimeAsync(2000);
await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0;
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
]);
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(await result).toBeInstanceOf(ServiceUnavailableException);
expect(jest.getTimerCount()).toBe(0);
});
it('stops retrying an unavailable upstream after three RPCs', async () => {
it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
jest.advanceTimersByTimeAsync(1000),
]);
expect(queue.run).toHaveBeenCalledTimes(3);
expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0);
});
+37 -47
View File
@@ -29,6 +29,14 @@ import type {
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,
TbankInstrumentType,
TbankOperationState,
} from './tbank-protocol';
const INITIAL_REQUEST_COUNT = 0;
const MIN_RATE_DELAY_MS = 1;
@@ -38,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000;
@@ -115,7 +121,7 @@ export type TbankOperationsByCursorRequest = {
from?: TbankTimestamp;
instrumentId?: string;
limit?: number;
state: 'OPERATION_STATE_EXECUTED';
state: TbankOperationState.Executed;
to?: TbankTimestamp;
};
export type TbankOperationsByCursorResponse = {
@@ -205,10 +211,10 @@ export class TbankClient {
loadPackageDefinition(definition) as unknown as TbankNamespace
).tinkoff.public.invest.api.contract.v1;
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();
? ChannelCredentials.createSsl(readFileSync(caPath))
: ChannelCredentials.createSsl();
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
const create = (name: string) =>
new (
@@ -256,7 +262,7 @@ export class TbankClient {
token,
this.users,
'getAccounts',
{ status: 'ACCOUNT_STATUS_OPEN' },
{ status: TbankAccountStatus.Open },
).then((response) => response.accounts ?? []);
}
@@ -279,14 +285,14 @@ export class TbankClient {
token: string,
figi: string,
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
return this.getInstrument(token, InstrumentIdType.Figi, figi);
}
getInstrumentByUid(
token: string,
instrumentUid: string,
): Promise<null | TbankInstrument> {
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
}
getDividends(
@@ -314,7 +320,7 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
instrumentKind: TbankInstrumentType.Share,
},
).then((response) => response.instruments ?? []);
}
@@ -326,7 +332,7 @@ export class TbankClient {
'findInstrument',
{
query,
instrumentKind: 'INSTRUMENT_TYPE_BOND',
instrumentKind: TbankInstrumentType.Bond,
},
).then((response) => response.instruments ?? []);
}
@@ -337,7 +343,7 @@ export class TbankClient {
this.sparseInstruments,
'bondBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
@@ -367,7 +373,7 @@ export class TbankClient {
this.instruments,
'shareBy',
{
idType: 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType.Uid,
id: uid,
},
).then((response) => response.instrument ?? null);
@@ -411,12 +417,6 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []);
}
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>(
token,
@@ -446,7 +446,7 @@ export class TbankClient {
to: {
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
},
interval: 'CANDLE_INTERVAL_DAY',
interval: TbankCandleInterval.Day,
limit: 2400,
},
).then((response) => response.candles ?? []);
@@ -454,7 +454,7 @@ export class TbankClient {
private async getInstrument(
token: string,
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
idType: InstrumentIdType,
id: string,
): Promise<null | TbankInstrument> {
try {
@@ -478,13 +478,12 @@ export class TbankClient {
request: object,
): Promise<Response> {
const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try {
if (queueFull) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
const response = await this.queue.run(() =>
this.request<Response>(token, client, method, request),
@@ -497,44 +496,35 @@ export class TbankClient {
return response;
} catch (error) {
const grpcCode = (error as ServiceError).code;
const failureContext = queueFull ?
{ code: 'TBANK_UNAVAILABLE' } :
{ grpcCode };
const retryable = queueFull ?
metadataCall :
[
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
const failureContext = queueFull
? { code: TbankErrorCode.Unavailable }
: { grpcCode };
const retryable = !queueFull && [
status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED,
status.UNAVAILABLE,
].includes(grpcCode);
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
this.logger.event('warn', 'tbank.rpc.retry', {
operation: method,
...failureContext,
attempts,
durationMs: Math.round(performance.now() - startedAt),
});
await new Promise((resolve) =>
setTimeout(
resolve,
metadataCall ?
attempts * METADATA_RETRY_DELAY_MS :
RPC_RETRY_DELAY_MS,
),
);
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
continue;
}
this.logger.event('error', 'tbank.rpc.failed', {
operation: method,
...failureContext,
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt),
});
if (
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
) {
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
throw new Error('INVALID_TBANK_TOKEN');
throw new Error(TbankErrorCode.InvalidToken);
}
if (
grpcCode === status.NOT_FOUND &&
@@ -542,10 +532,10 @@ export class TbankClient {
) {
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>(
-3
View File
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config';
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
});
app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes(
new ValidationPipe({
forbidUnknownValues: true,
@@ -0,0 +1,4 @@
export enum AuthErrorCode {
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
InvalidCredentials = 'INVALID_CREDENTIALS',
}
+19 -16
View File
@@ -8,8 +8,11 @@ import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthUser, RefreshPayload } from './auth.types';
import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
@@ -32,12 +35,12 @@ export class AuthService {
async register(
email: string,
password: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
): Promise<AuthenticationResult> {
const existing = await this.prisma.user.findUnique({
where: { email },
});
if (existing) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
let user: User;
@@ -51,7 +54,7 @@ export class AuthService {
});
} catch (error) {
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
}
throw error;
}
@@ -61,7 +64,7 @@ export class AuthService {
async login(
email: string,
password: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
): Promise<AuthenticationResult> {
const user = await this.prisma.user.findUnique({
where: { email },
});
@@ -70,24 +73,24 @@ export class AuthService {
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
);
if (!user || !valid) {
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
}
return this.createSession(user);
}
async refresh(
token: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
): Promise<AuthenticationResult> {
let payload: RefreshPayload;
try {
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
algorithms: ['HS256'],
});
} catch {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
@@ -102,10 +105,10 @@ export class AuthService {
if (session && !session.revokedAt) {
await this.prisma.session.delete({ where: { id: session.id } });
}
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
if (session.refreshTokenHash !== this.hash(token)) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return this.rotateSession(session, session.refreshTokenHash);
}
@@ -142,13 +145,13 @@ export class AuthService {
async userById(id: string): Promise<User> {
const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user;
}
private async createSession(
user: User,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
): Promise<AuthenticationResult> {
const session = await this.prisma.session.create({
data: {
id: randomUUID(),
@@ -163,7 +166,7 @@ export class AuthService {
private async rotateSession(
session: Session & { user: User },
expectedRefreshTokenHash?: string,
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
): Promise<AuthenticationResult> {
const refreshToken = await this.jwt.signAsync(
{
sub: session.userId,
@@ -183,7 +186,7 @@ export class AuthService {
data,
});
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
typeof error === 'object' &&
error !== null &&
'code' in error &&
(error as { code?: unknown }).code === 'P2002'
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
@@ -3,6 +3,12 @@ export interface AuthUser {
id: string;
}
export type AuthenticationResult = {
accessToken: string;
refreshToken: string;
user: AuthUser;
};
export interface AccessPayload {
sid: string;
sub: string;
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types';
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
validate(payload: AccessPayload): AccessPayload {
if (payload.type !== 'access') {
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
}
return payload;
}
@@ -21,8 +21,9 @@ import {
} from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service';
import { AccessPayload } from '../application/auth.types';
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
@@ -84,14 +85,9 @@ export class AuthController {
@Req() request: Request,
@Res({ passthrough: true }) response: Response,
) {
try {
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
const token = this.refreshToken(request);
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return this.respond(response, await this.auth.refresh(token));
}
@Post('logout')
@@ -116,11 +112,7 @@ export class AuthController {
private respond(
response: Response,
result: {
accessToken: string;
refreshToken: string;
user: { email: string; id: string };
},
result: AuthenticationResult,
) {
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
return { user: result.user, accessToken: result.accessToken };
@@ -1,12 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types';
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) {
constructor(private readonly logger: PinoLogger) {
super();
}
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
.switchToHttp()
.getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub });
this.logger.assign({ userId: request.user.sub });
}
return Boolean(allowed);
}
@@ -22,24 +22,23 @@ import {
const NEXT_MONTH_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0;
export const BankErrorCode = {
InvalidInput: 'BANK_INVALID_INPUT',
VersionConflict: 'BANK_VERSION_CONFLICT',
NotFound: 'BANK_NOT_FOUND',
InvalidMoney: 'BANK_INVALID_MONEY',
InvalidDate: 'BANK_INVALID_DATE',
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
InvalidRefund: 'BANK_INVALID_REFUND',
RefundLimit: 'BANK_REFUND_LIMIT',
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
IntentConflict: 'BANK_INTENT_CONFLICT',
OperationDeleted: 'BANK_OPERATION_DELETED',
CardArchived: 'BANK_CARD_ARCHIVED',
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
WriteConflict: 'BANK_WRITE_CONFLICT',
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
} as const;
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
export enum BankErrorCode {
CardArchived = 'BANK_CARD_ARCHIVED',
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
IntentConflict = 'BANK_INTENT_CONFLICT',
InvalidDate = 'BANK_INVALID_DATE',
InvalidInput = 'BANK_INVALID_INPUT',
InvalidMoney = 'BANK_INVALID_MONEY',
InvalidRefund = 'BANK_INVALID_REFUND',
NotFound = 'BANK_NOT_FOUND',
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
OperationDeleted = 'BANK_OPERATION_DELETED',
RefundLimit = 'BANK_REFUND_LIMIT',
VersionConflict = 'BANK_VERSION_CONFLICT',
WriteConflict = 'BANK_WRITE_CONFLICT',
}
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
throw new BadRequestException({ code });
@@ -103,17 +103,13 @@ export class BankCardsService {
id,
name: dto.name ?? previous.name,
bankName:
dto.bankName === undefined ?
(previous.bankName ?? undefined) :
(dto.bankName ?? undefined),
dto.bankName === undefined ? previous.bankName : dto.bankName,
lastFour:
dto.lastFour === undefined ?
(previous.lastFour ?? undefined) :
(dto.lastFour ?? undefined),
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
paymentSystem:
dto.paymentSystem === undefined ?
previous.paymentSystem :
dto.paymentSystem,
dto.paymentSystem === undefined
? previous.paymentSystem
: dto.paymentSystem,
openingBalance:
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
openingDate: dto.openingDate ?? previous.openingDate,
@@ -143,12 +139,12 @@ export class BankCardsService {
...input,
paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 },
...(dto.archived === undefined ?
{} :
{
archivedAt: dto.archived ?
(previous.archivedAt ?? new Date()) :
null,
...(dto.archived === undefined
? {}
: {
archivedAt: dto.archived
? (previous.archivedAt ?? new Date())
: null,
}),
},
});
@@ -43,29 +43,29 @@ export class BankOperationsService {
row: BankCardOperation,
): Promise<BankOperationDto> {
const card = await this.repository.card(tx, row.userId, row.cardId);
const target = row.targetCardId ?
await this.repository.card(tx, row.userId, row.targetCardId) :
null;
const source = row.originalExpenseId ?
await this.repository.operation(tx, row.userId, row.originalExpenseId) :
null;
const target = row.targetCardId
? await this.repository.card(tx, row.userId, row.targetCardId)
: null;
const source = row.originalExpenseId
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId ?
await tx.expenseCategory.findFirst({
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
}) :
null;
})
: null;
const refunds =
row.kind === BankOperationKind.Expense || source ?
await tx.bankCardOperation.findMany({
row.kind === BankOperationKind.Expense || source
? await tx.bankCardOperation.findMany({
where: {
userId: row.userId,
originalExpenseId: source?.id ?? row.id,
deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
}) :
[];
})
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
@@ -78,12 +78,12 @@ export class BankOperationsService {
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source ?
rubMoney(
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
) :
null,
)
: null,
date: row.date,
description: row.description,
version: row.version,
@@ -336,9 +336,9 @@ export class BankOperationsService {
query: BankOperationsQueryDto | BankPageQueryDto,
expenses = false,
) {
const period = expenses ?
undefined :
periodBounds(query as BankOperationsQueryDto);
const period = expenses
? undefined
: periodBounds(query as BankOperationsQueryDto);
return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
@@ -347,9 +347,9 @@ export class BankOperationsService {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period ?
{ date: { gte: period.from, lte: period.to } } :
{ kind: BankOperationKind.Expense }),
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
@@ -81,13 +81,13 @@ export function bankSummary(
continue;
}
const expense =
operation.kind === BankOperationKind.Expense ?
operation.amountKopecks :
ZERO_KOPECKS;
operation.kind === BankOperationKind.Expense
? operation.amountKopecks
: ZERO_KOPECKS;
const refund =
operation.kind === BankOperationKind.Refund ?
operation.amountKopecks :
ZERO_KOPECKS;
operation.kind === BankOperationKind.Refund
? operation.amountKopecks
: ZERO_KOPECKS;
expenses += expense;
refunds += refund;
if (operation.categoryId !== null) {
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
BankErrorCode,
@@ -40,19 +41,19 @@ export class BankCardsRepository {
});
} catch (error) {
const code =
error instanceof Prisma.PrismaClientKnownRequestError ?
error.code :
'';
error instanceof Prisma.PrismaClientKnownRequestError
? error.code
: '';
const busy =
code === 'P2034' ||
code === 'P2028' ||
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
continue;
}
if (busy) conflict(BankErrorCode.WriteConflict);
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
throw error;
}
}
@@ -1 +0,0 @@
export { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -12,15 +12,15 @@ export class BrokerageAccountsService {
async list(userId: string, observe = false) {
const saved = await this.portfolio.saved(userId, 'all', observe);
const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' ?
null :
saved.accounts.map((account) => ({
saved.membership.availability === 'missing'
? null
: saved.accounts.map((account) => ({
id: account.id,
name: account.name,
type: account.type as BrokerageAccountDto['type'],
openedOn: account.openedOn,
portfolioSummary: account.snapshot ?
{
portfolioSummary: account.snapshot
? {
totalsByCurrency: account.snapshot.totalsByCurrency,
stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK',
@@ -28,8 +28,8 @@ export class BrokerageAccountsService {
bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND',
).length,
} :
null,
}
: null,
}));
return {
data,
@@ -47,18 +47,18 @@ export class BrokerageAccountsService {
)
.digest('base64url'),
availability:
data === null ?
'missing' :
saved.accounts.some((account) => !account.snapshot) ?
'partial' :
'complete',
fetchedAt: saved.membership.lastSuccessfulAt ?
new Date(saved.membership.lastSuccessfulAt) :
null,
data === null
? 'missing'
: saved.accounts.some((account) => !account.snapshot)
? 'partial'
: 'complete',
fetchedAt: saved.membership.lastSuccessfulAt
? new Date(saved.membership.lastSuccessfulAt)
: null,
freshForMs: 300_000,
work: saved.membership.sync.id ?
{ ...saved.membership.sync, id: saved.membership.sync.id } :
null,
work: saved.membership.sync.id
? { ...saved.membership.sync, id: saved.membership.sync.id }
: null,
dependencies: saved.accounts.map((account) => account.meta),
}),
};
@@ -189,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
).toEqual(['10.25', '-0.25']);
});
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
seed([
{
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
occurredAt: '2026-01-14T20:59:59.999Z',
},
{
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
occurredAt: '2026-01-14T21:00:00.000Z',
},
{
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
occurredAt: '2026-01-15T20:59:59.999Z',
},
]);
const response = await service.analytics(
'user',
'account',
'2026-01-14',
'2026-01-16',
);
expect(response.data?.buckets).toEqual([
{
period: { from: '2026-01-14', to: '2026-01-14' },
totalsByCurrency: [expect.objectContaining({
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-15', to: '2026-01-15' },
totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-16', to: '2026-01-16' },
totalsByCurrency: [],
},
]);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation(
(
@@ -8,6 +8,7 @@ import {
formatDecimal,
subtract,
} from '../../../common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper';
import {
@@ -96,9 +97,9 @@ export class IncomeAnalyticsService {
{ ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' },
{
...(plan.yearAgo && year ?
year.meta :
investmentResourceMeta({
...(plan.yearAgo && year
? year.meta
: investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable',
availability: 'missing',
@@ -119,9 +120,9 @@ export class IncomeAnalyticsService {
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision,
availability: current.meta.availability,
fetchedAt: current.meta.lastSuccessfulAt ?
new Date(current.meta.lastSuccessfulAt) :
null,
fetchedAt: current.meta.lastSuccessfulAt
? new Date(current.meta.lastSuccessfulAt)
: null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work?.id ? { ...work, id: work.id } : null,
coverage: current.meta.coverage,
@@ -144,13 +145,13 @@ export class IncomeAnalyticsService {
current.meta.lastSuccessfulAt,
),
previous:
prior.items === null ?
null :
comparison(selected, plan.previous, qualifying(prior.items)),
prior.items === null
? null
: comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison:
plan.yearAgo && year?.items !== null && year ?
comparison(selected, plan.yearAgo, qualifying(year.items)) :
null,
plan.yearAgo && year?.items !== null && year
? comparison(selected, plan.yearAgo, qualifying(year.items))
: null,
bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected),
@@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
return entries.filter(
(entry) =>
Boolean(entry.payment) &&
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type),
[TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
);
}
@@ -184,7 +185,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value);
if (entry.type === 'OPERATION_TYPE_COUPON') {
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value);
} else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
@@ -231,13 +232,16 @@ function buckets(
granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[],
) {
const datedEntries = entries.map((entry) => ({
entry,
date: moscowDate(new Date(entry.occurredAt)),
}));
return rangeBuckets(range, granularity).map((period) => ({
period,
totalsByCurrency: totals(
entries.filter((entry) => {
const date = moscowDate(new Date(entry.occurredAt));
return date >= period.from && date <= period.to;
}),
datedEntries
.filter(({ date }) => date >= period.from && date <= period.to)
.map(({ entry }) => entry),
),
}));
}
+24 -22
View File
@@ -8,6 +8,8 @@ import {
utcDate,
} from '../investment-calendar';
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1;
@@ -39,24 +41,24 @@ export function planIncomePeriod(
const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today);
const previous = isFullMonth(selected) ?
previousMonth(selected.from) :
shiftDays(selected, -rangeDays(selected));
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
candidate :
null;
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return {
selected,
previous,
yearAgo: yearAgoRange,
granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
'day' :
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
'week' :
'month',
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day'
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
? 'week'
: 'month',
};
}
@@ -69,20 +71,20 @@ export function rangeBuckets(
}
const buckets: DateOnlyRange[] = [];
let cursor =
granularity === 'week' ?
addDays(
granularity === 'week'
? addDays(
range.from,
-(
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
DAYS_PER_WEEK
),
) :
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
)
: `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
while (cursor <= range.to) {
const end =
granularity === 'week' ?
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
endOfMonth(cursor);
granularity === 'week'
? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
: endOfMonth(cursor);
buckets.push({
from: cursor < range.from ? range.from : cursor,
to: end > range.to ? range.to : end,
@@ -153,9 +155,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
result :
null;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
@@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
}
function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
}
@@ -1 +0,0 @@
export { IncomeAnalyticsService } from './income-analytics.service';
@@ -5,10 +5,9 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { formatDecimal } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankClient,
@@ -16,19 +15,18 @@ import {
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { planInvestmentScreen } from '../../investment-sync-plan';
import {
candleGaps,
type CandleRange,
candleWindows,
utcDay,
} from '../domain/candle-coverage';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { CandleRepository } from '../infrastructure/candle.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1;
@@ -54,7 +52,6 @@ type Producer = {
};
const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000;
const prefetchReadBatchSize = 100;
function scope(input: CandleInput) {
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
}
@@ -70,9 +67,9 @@ function rangeFor(
if (!history) {
throw new ServiceUnavailableException({
code:
input.instrumentType === 'share' ?
'STOCK_HISTORY_START_UNAVAILABLE' :
'BOND_HISTORY_START_UNAVAILABLE',
input.instrumentType === 'share'
? InstrumentErrorCode.StockHistoryStartUnavailable
: InstrumentErrorCode.BondHistoryStartUnavailable,
});
}
from.setTime(utcDay(new Date(history)).valueOf());
@@ -92,7 +89,6 @@ export class CandleService {
private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve();
private running = false;
private readonly prefetches = new Map<string, Promise<void>>();
constructor(
private readonly repository: CandleRepository,
private readonly sources: CatalogEnrichmentService,
@@ -114,9 +110,9 @@ export class CandleService {
throw new NotFoundException();
}
const now = new Date();
const range = detail.instrument ?
rangeFor(input, detail.instrument, now) :
null;
const range = detail.instrument
? rangeFor(input, detail.instrument, now)
: null;
const saved = range ? await this.repository.read(input.uid, range) : null;
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
if (!observe && (!saved || gaps.length)) {
@@ -139,14 +135,14 @@ export class CandleService {
});
return {
data:
!saved || !covered ?
null :
{
!saved || !covered
? null
: {
candles: saved.data,
currency:
input.instrumentType === 'share' ?
detail.instrument?.currency?.toUpperCase() || null :
null,
input.instrumentType === 'share'
? detail.instrument?.currency?.toUpperCase() || null
: null,
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
},
@@ -159,8 +155,8 @@ export class CandleService {
work,
coverage: {
requested: range ? [publicRange(range)] : [],
completeRanges: range ?
completeCoverage.flatMap((row) => {
completeRanges: range
? completeCoverage.flatMap((row) => {
const from = new Date(
Math.max(row.from.valueOf(), range.from.valueOf()),
);
@@ -168,22 +164,22 @@ export class CandleService {
Math.min(row.to.valueOf(), range.to.valueOf()),
);
return from < to ? [publicRange({ from, to })] : [];
}) :
[],
})
: [],
missingRanges: missing.map(publicRange),
asOf: fetchedAt?.toISOString() ?? null,
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
'known' :
'unknown',
historyState: complete ?
'complete' :
work?.state === 'failed' ?
'failed' :
work?.state === 'running' ?
'loading' :
covered ?
'partial' :
'missing',
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
? 'known'
: 'unknown',
historyState: complete
? 'complete'
: work?.state === 'failed'
? 'failed'
: work?.state === 'running'
? 'loading'
: covered
? 'partial'
: 'missing',
},
dependencies: [
detail.meta,
@@ -193,9 +189,9 @@ export class CandleService {
dataRevision: String(row.dataRevision),
availability: 'complete',
fetchedAt: row.fetchedAt,
freshForMs: row.isComplete ?
Number.POSITIVE_INFINITY :
tailFreshForMs,
freshForMs: row.isComplete
? Number.POSITIVE_INFINITY
: tailFreshForMs,
work: null,
}),
),
@@ -204,110 +200,6 @@ export class CandleService {
};
}
prefetch(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const key = JSON.stringify([
userId,
revision,
positions
.map((position) => [position.instrumentUid, position.type])
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
.sort(),
]);
const existing = this.prefetches.get(key);
if (existing) return existing;
const work = this.prefetchPositions(userId, revision, positions).finally(
() => this.prefetches.delete(key),
);
this.prefetches.set(key, work);
return work;
}
private async prefetchPositions(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const now = new Date();
const unique = new Map(
positions
.filter(
(position) =>
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
)
.map((position) => [position.instrumentUid ?? '', position]),
);
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
for (const instrumentType of ['share', 'bond'] as const) {
const uids = [...unique]
.filter(
([, position]) =>
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
)
.map(([uid]) => uid);
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += prefetchReadBatchSize
) {
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
details.push(
...(await this.sources.readMany(userId, instrumentType, batch, true, [
'detail',
])),
);
coverage.push(...(await this.repository.coverage(batch)));
}
}
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
for (const [uid, position] of unique) {
const instrumentType: CandleInput['instrumentType'] =
position.type === 'STOCK' ? 'share' : 'bond';
const detail = byUid.get(uid);
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
const plan = planInvestmentScreen(
{ screen: 'account' },
{
membershipKnown: true,
accounts: [],
instruments: [
{
uid,
type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null,
},
],
},
now,
);
if (
!detail?.instrument ||
plan.sources.some((source) => source.kind === 'candles')
) {
const input = { uid, instrumentType, period: 'all' as const };
const range =
detail?.instrument && history ?
rangeFor(input, detail.instrument, now) :
null;
if (
!range ||
candleGaps(
range,
coverage.filter((row) => row.instrumentUid === uid),
now,
).length
) {
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
}
}
}
}
request(
userId: string,
revision: string,
@@ -411,17 +303,7 @@ export class CandleService {
window.from,
new Date(Math.min(window.to.valueOf(), now.valueOf())),
);
const rows = candles.flatMap((candle) => {
const time = timestamp(candle.time);
if (!time || !candle.close) throw new Error('Invalid candle response');
return [
{
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
},
];
});
const rows = candles.map(toSavedCandle);
if (!(await this.repository.stage(attempt, window, rows, now))) {
return false;
}
@@ -448,7 +330,7 @@ export class CandleService {
const producer = this.queue.shift();
if (!producer) break;
let success = false;
let code = 'TBANK_UNAVAILABLE';
let code: string = TbankErrorCode.Unavailable;
try {
const result = await this.load(producer);
if (result === null) {
@@ -457,7 +339,7 @@ export class CandleService {
}
success = result;
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt);
}
if (error instanceof ServiceUnavailableException) {
@@ -508,22 +390,11 @@ export class CandleService {
this.reservations = finished.catch(() => undefined);
if (!(await finished)) {
this.queue.push(producer);
continue;
}
if (success && !producer.force && producer.input.period !== 'all') {
await this.request(
producer.attempt.userId,
producer.attempt.revision,
{ ...producer.input, period: 'all' },
producer.now,
false,
BACKGROUND_SYNC_PRIORITY,
);
}
}
} catch {
this.logger.event('error', 'candles.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
} finally {
this.active.clear();
@@ -6,6 +6,7 @@ import {
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankClient,
@@ -14,6 +15,7 @@ import {
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
import {
CatalogDiscoveryRepository,
catalogQuery,
@@ -79,14 +81,14 @@ export class CatalogDiscoveryService {
async search(userId: string, input: CatalogQuery, observe = false) {
const discovery = await this.read(userId, input, observe);
const sources = discovery.data ?
await this.enrichment.readMany(
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
) :
[];
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
@@ -114,22 +116,22 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null ?
'missing' :
sources.every((source) => source.meta.availability === 'complete') ?
'complete' :
'partial',
fetchedAt: discovery.meta.lastSuccessfulAt ?
new Date(discovery.meta.lastSuccessfulAt) :
null,
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.availability === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.catalog,
work: discovery.meta.sync.id ?
{
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
} :
null,
}
: null,
dependencies,
}),
};
@@ -177,17 +179,17 @@ export class CatalogDiscoveryService {
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt ?
new Date(source.meta.lastSuccessfulAt) :
null,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: source.meta.sync.id ?
{
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
} :
null,
}
: null,
dependencies,
}),
};
@@ -208,36 +210,36 @@ export class CatalogDiscoveryService {
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share' ?
'STOCK_TICKER_AMBIGUOUS' :
'BOND_TICKER_AMBIGUOUS',
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = lookup.data ?
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
null;
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data ?
{ ...result.data, slug: ticker.trim().toUpperCase() } :
null,
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt ?
new Date(lookup.meta.lastSuccessfulAt) :
null,
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.metadata,
work: lookup.meta.sync.id ?
{
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
} :
null,
}
: null,
dependencies,
}),
};
@@ -334,19 +336,19 @@ export class CatalogDiscoveryService {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share' ?
await this.tbank.findShares(token, input.query) :
await this.tbank.findBonds(token, input.query);
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker ?
found.filter(
input.exactTicker
? found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
) :
found,
)
: found,
input.query,
input.instrumentType,
).slice(
@@ -357,11 +359,11 @@ export class CatalogDiscoveryService {
);
}
} catch (error) {
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'catalog-discovery.failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
}
}
@@ -395,7 +397,7 @@ export class CatalogDiscoveryService {
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
} finally {
this.running = false;
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
import {
decimalFromString,
divide,
formatDecimal,
} from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
@@ -13,7 +12,7 @@ import {
} from '../../../../common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
type TbankBond,
@@ -36,6 +35,7 @@ import {
catalogScope,
type CatalogSource,
} from '../infrastructure/catalog-discovery.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { catalogSearchLimit } from './catalog-selection';
import { previousClose, timestamp } from './instrument-market';
@@ -124,9 +124,9 @@ export class CatalogEnrichmentService {
range,
);
}
const work = inputs.length ?
await this.repository.workMany(userId, revision, inputs) :
[];
const work = inputs.length
? await this.repository.workMany(userId, revision, inputs)
: [];
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
return inputs.map((input) => {
const row = rows.find(
@@ -136,16 +136,16 @@ export class CatalogEnrichmentService {
const payload = row?.payload as CatalogPayload | undefined;
return {
input,
version: row ?
{
version: row
? {
instrumentUid: row.instrumentUid,
source: row.source,
kind: row.kind,
rangeKey: row.rangeKey,
dataRevision: row.dataRevision,
fetchedAt: row.fetchedAt,
} :
null,
}
: null,
payload,
instrument: payload?.instrument ?? null,
price: payload?.price ?? null,
@@ -177,9 +177,9 @@ export class CatalogEnrichmentService {
) {
const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => {
const rows = inputs.length ?
await this.repository.readSources(inputs) :
[];
const rows = inputs.length
? await this.repository.readSources(inputs)
: [];
const ids: string[] = [];
for (const input of inputs) {
const row = rows.find(
@@ -242,18 +242,18 @@ export class CatalogEnrichmentService {
observe = false,
) {
const kinds: CatalogSource['kind'][] =
section === 'company' ?
['detail', 'description', 'fundamentals'] :
[
...(instrumentType === 'bond' ?
['bond-current' as const] :
['detail' as const]),
section === 'company'
? ['detail', 'description', 'fundamentals']
: [
...(instrumentType === 'bond'
? ['bond-current' as const]
: ['detail' as const]),
'last-price',
'trading-status',
'comparison',
...(section === 'metrics' ?
['coupons' as const, 'yield' as const, 'rating' as const] :
[]),
...(section === 'metrics'
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const sources = await this.readMany(
userId,
@@ -279,18 +279,18 @@ export class CatalogEnrichmentService {
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: !first.instrument ?
'missing' :
complete ?
'complete' :
'partial',
fetchedAt: first.meta.lastSuccessfulAt ?
new Date(first.meta.lastSuccessfulAt) :
null,
availability: !first.instrument
? 'missing'
: complete
? 'complete'
: 'partial',
fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt)
: null,
freshForMs:
section === 'company' ?
tbankSourceTtlMs.company :
tbankSourceTtlMs.quote,
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
work: null,
dependencies,
});
@@ -315,9 +315,9 @@ export class CatalogEnrichmentService {
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share' ?
await this.tbank.getShare(token, input.uid) :
await this.tbank.getBond(token, input.uid);
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
@@ -352,17 +352,7 @@ export class CatalogEnrichmentService {
gap.from,
gap.to,
);
const rows = response.map((candle) => {
const time = timestamp(candle.time);
if (!time || !candle.close) {
throw new Error('Invalid candle response');
}
return {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
};
});
const rows = response.map(toSavedCandle);
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
@@ -400,9 +390,9 @@ export class CatalogEnrichmentService {
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond' ?
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
[]),
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
@@ -415,36 +405,36 @@ export class CatalogEnrichmentService {
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid ?
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
null,
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid ?
await this.tbank.getFundamentals(token, instrument?.assetUid) :
null,
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[') ?
(JSON.parse(input.rangeKey) as [string, string]) :
[
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons' ?
{
return input.kind === 'coupons'
? {
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
} :
{
}
: {
dividends: await this.tbank.getDividends(
token,
input.uid,
@@ -461,20 +451,20 @@ export class CatalogEnrichmentService {
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode ?
await this.moex.bondYield(
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
) :
null,
)
: null,
};
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin ?
await this.ratings.rating(instrument.isin) :
null,
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
};
}
throw new Error('Unsupported catalog source');
@@ -520,11 +510,11 @@ export class CatalogEnrichmentService {
}
} catch (error) {
failed = true;
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(producer.attempt);
} else {
this.logger.event('warn', 'catalog-enrichment.failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
}
}
@@ -533,17 +523,17 @@ export class CatalogEnrichmentService {
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
null;
const value =
item.input.kind === 'last-price' ?
{
price: price ?
{
item.input.kind === 'last-price'
? {
price: price
? {
instrumentUid: item.input.uid,
price: price.price,
time: price.time,
} :
null,
} :
payload;
}
: null,
}
: payload;
// Keep accepting observers while publication awaits SQLite.
item.observers.unshift({ attempt: item.attempt, input: item.input });
for (const observer of item.observers) {
@@ -572,7 +562,7 @@ export class CatalogEnrichmentService {
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
code: 'TBANK_UNAVAILABLE',
code: TbankErrorCode.Unavailable,
});
} finally {
this.running = false;
@@ -596,17 +586,17 @@ function sourceInputs(
uid,
kind,
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ?
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
{ ticker: range?.tickers?.[uid] || range?.ticker } :
{}),
...(['coupons', 'dividends'].includes(kind) ?
{
rangeKey: range ?
JSON.stringify([range.from, range.to]) :
String(new Date().getUTCFullYear()),
} :
{}),
? { ticker: range?.tickers?.[uid] || range?.ticker }
: {}),
...(['coupons', 'dividends'].includes(kind)
? {
rangeKey: range
? JSON.stringify([range.from, range.to])
: String(new Date().getUTCFullYear()),
}
: {}),
})),
);
}
@@ -679,9 +669,9 @@ function sourcePolicy(kind: CatalogSource['kind']) {
}
function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' &&
(input.kind === 'detail' || input.kind === 'bond-current') ?
`bond-metadata:${input.uid}` :
catalogScope(input);
(input.kind === 'detail' || input.kind === 'bond-current')
? `bond-metadata:${input.uid}`
: catalogScope(input);
}
const quotationScale = 9;
@@ -1,5 +1,7 @@
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30;
export function selectCatalogInstruments<T extends TbankShare>(
@@ -9,9 +11,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
): (T & { uid: string })[] {
const seen = new Set<string>();
const kind =
instrumentType === 'share' ?
'INSTRUMENT_TYPE_SHARE' :
'INSTRUMENT_TYPE_BOND';
instrumentType === 'share'
? TbankInstrumentType.Share
: TbankInstrumentType.Bond;
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
return found
.filter(
@@ -29,9 +31,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
a.uid.localeCompare(b.uid),
)
.filter((instrument) => {
const key = instrument.isin?.trim() ?
`isin:${instrument.isin.trim().toUpperCase()}` :
`uid:${instrument.uid}`;
const key = instrument.isin?.trim()
? `isin:${instrument.isin.trim().toUpperCase()}`
: `uid:${instrument.uid}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
@@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
const MOSCOW_OFFSET_MS = 10_800_000;
export function timestamp(value?: TbankTimestamp): null | string {
const seconds = Number(value?.seconds);
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
null;
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
: null;
}
export function moscowDay(iso: string): string {
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
@@ -43,8 +43,8 @@ export function instrumentPosition(
currentValue,
absoluteReturn,
averagePurchasePrice:
positive && totalCost ?
{
positive && totalCost
? {
currency: totalCost.currency,
value: formatDecimal(
divide(
@@ -53,15 +53,15 @@ export function instrumentPosition(
PRICE_DECIMAL_PLACES,
),
),
} :
null,
}
: null,
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
accounts: positions.map((p) => ({
...p,
returnPercent:
decimalFromString(p.quantity).value > ZERO_QUANTITY ?
percent(p.absoluteReturn, cost(p)) :
null,
decimalFromString(p.quantity).value > ZERO_QUANTITY
? percent(p.absoluteReturn, cost(p))
: null,
})),
isStale: portfolio.isStale,
observedAt: portfolio.lastSuccessfulAt,
@@ -69,8 +69,8 @@ export function instrumentPosition(
}
function cost(position: PortfolioPositionDto): MoneyDto | null {
const price = position.averagePurchasePrice;
return price ?
{
return price
? {
currency: price.currency,
value: formatDecimal(
multiply(
@@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
decimalFromString(position.quantity),
),
),
} :
null;
}
: null;
}
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
const currency = values[0]?.currency;
@@ -37,18 +37,18 @@ export class BondCatalogService {
data:
result.data?.map((item) => {
const short = toBond(item);
const detail = item.detail ?
toBond({ ...item.detail, uid: item.uid }) :
short;
const detail = item.detail
? toBond({ ...item.detail, uid: item.uid })
: short;
const last = item.price;
return {
...detail,
classCode: detail.classCode ?? short.classCode,
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
pricePercent: last?.price ?
formatDecimal(decimal(last.price)) :
null,
pricePercent: last?.price
? formatDecimal(decimal(last.price))
: null,
priceAt: timestamp(last?.time),
};
}) ?? null,
@@ -63,9 +63,9 @@ export class BondCatalogService {
const result = await this.discovery.detail(userId, 'bond', uid, observe);
return {
...result,
data: result.data ?
{ ...toBond(result.data), slug: result.data.slug } :
null,
data: result.data
? { ...toBond(result.data), slug: result.data.slug }
: null,
};
}
@@ -82,9 +82,9 @@ export class BondCatalogService {
);
return {
...result,
data: result.data ?
{ ...toBond(result.data), slug: result.data.slug } :
null,
data: result.data
? { ...toBond(result.data), slug: result.data.slug }
: null,
};
}
}
@@ -7,10 +7,12 @@ import {
multiply,
} from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
import {
TbankAccessService,
type TbankBond,
type TbankCoupon,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
@@ -62,9 +64,9 @@ export class BondIncomeService {
observe,
);
return {
data: result.values.instrument ?
bondMetrics(result.values, result.unavailable, result.observedAt) :
null,
data: result.values.instrument
? bondMetrics(result.values, result.unavailable, result.observedAt)
: null,
meta: result.meta,
};
}
@@ -99,18 +101,18 @@ export class BondIncomeService {
throw new NotFoundException();
}
const events = sources[1].payload?.coupons;
const position = portfolio.data ?
instrumentPosition(portfolio.data, uid, 'BOND') :
null;
const projected = events ?
bondCoupons(
const position = portfolio.data
? instrumentPosition(portfolio.data, uid, 'BOND')
: null;
const projected = events
? bondCoupons(
events,
position,
{ from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data,
) :
undefined;
)
: undefined;
const result = await this.forecasts.result({
userId,
revision: connection.revision,
@@ -121,9 +123,13 @@ export class BondIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
return result.data || !projected ?
result :
{
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
data: bondCoupons(
events ?? [],
null,
@@ -146,19 +152,19 @@ export function bondCoupons(
const { from, to, today } = window;
const quantity =
position?.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
decimalFromString(position.quantity) :
null;
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
? decimalFromString(position.quantity)
: null;
const coupons = events
.map((event) => {
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const perBond = money(event.payOneBond);
const timing = !paymentDate ?
('undated' as const) :
paymentDate >= today ?
('upcoming' as const) :
('past' as const);
const timing = !paymentDate
? ('undated' as const)
: paymentDate >= today
? ('upcoming' as const)
: ('past' as const);
return {
number: event.couponNumber ?? null,
paymentDate,
@@ -172,14 +178,14 @@ export function bondCoupons(
perBond &&
timing === 'upcoming' &&
recordDate &&
recordDate >= today ?
{
recordDate >= today
? {
currency: perBond.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)),
),
} :
null,
}
: null,
};
})
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
@@ -225,8 +231,8 @@ export function bondMetrics(
clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT ?
formatDecimal(
decimalFromString(clean.value).value > ZERO_AMOUNT
? formatDecimal(
divide(
multiply(
decimalFromString(coupon.value),
@@ -238,14 +244,14 @@ export function bondMetrics(
),
COUPON_YIELD_DECIMAL_PLACES,
),
) :
null;
)
: null;
const couponType =
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
next.couponType :
instrument.floatingCouponFlag === true ?
'COUPON_TYPE_FLOATING' :
null;
next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
? next.couponType
: instrument.floatingCouponFlag === true
? TbankCouponType.Floating
: null;
return {
creditRating,
coupon,
@@ -48,9 +48,9 @@ export class BondMarketService {
observe,
);
return {
data: result.values.instrument ?
bondQuote(result.values, result.unavailable, result.observedAt) :
null,
data: result.values.instrument
? bondQuote(result.values, result.unavailable, result.observedAt)
: null,
meta: result.meta,
};
}
@@ -83,8 +83,8 @@ export function bondQuote(
const cleanPrice =
pricePercent !== null &&
nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT ?
{
decimalFromString(nominal.value).value > ZERO_AMOUNT
? {
currency: nominal.currency,
value: formatDecimal(
divide(
@@ -96,11 +96,11 @@ export function bondQuote(
PRICE_DECIMAL_PLACES,
),
),
} :
null;
}
: null;
const dirtyPrice =
cleanPrice && aci && aci.currency === cleanPrice.currency ?
{
cleanPrice && aci && aci.currency === cleanPrice.currency
? {
currency: cleanPrice.currency,
value: formatDecimal(
add(
@@ -108,8 +108,8 @@ export function bondQuote(
decimalFromString(aci.value),
),
),
} :
null;
}
: null;
return {
pricePercent,
cleanPrice,
@@ -118,9 +118,9 @@ export function bondQuote(
priceAt,
tradingStatus,
changePercentagePoints:
last?.price && previous?.close ?
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
null,
last?.price && previous?.close
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
: null,
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
unavailable,
observedAt,
@@ -123,9 +123,9 @@ describe('bond financial projections', () => {
{
couponDate: time('2026-10-10'),
payOneBond:
scenario === 'unknown' ?
undefined :
money('30', scenario === 'currency' ? 'usd' : 'rub'),
scenario === 'unknown'
? undefined
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
couponPeriod: scenario === 'period' ? 0 : 90,
},
],
@@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -38,9 +38,9 @@ export function candleGaps(
gaps = gaps.flatMap((gap) => {
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
return [
...(covered.from > gap.from ?
[{ from: gap.from, to: covered.from }] :
[]),
...(covered.from > gap.from
? [{ from: gap.from, to: covered.from }]
: []),
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
];
});
@@ -0,0 +1,6 @@
export enum InstrumentErrorCode {
BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE',
BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS',
StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE',
StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS',
}
@@ -53,12 +53,6 @@ export class CandleRepository {
});
}
coverage(uids: string[]) {
return this.prisma.tbankCandleCoverage.findMany({
where: { provider, instrumentUid: { in: uids }, interval },
});
}
reserve(
owner: { revision: string; userId: string },
scopeKey: string,
@@ -100,14 +94,14 @@ export class CandleRepository {
priority: nextPriority,
intent: {
...previousIntent,
...(intent.force ?
{
...(intent.force
? {
force: true,
...(intent.now && intent.now > previousNow ?
{ now: intent.now } :
{}),
} :
{}),
...(intent.now && intent.now > previousNow
? { now: intent.now }
: {}),
}
: {}),
},
},
});
@@ -263,12 +257,12 @@ export class CandleRepository {
// Preserve coverage outside the requested reconciliation boundaries.
for (const previous of old) {
for (const part of [
...(previous.from < range.from ?
[{ from: previous.from, to: range.from }] :
[]),
...(previous.to > range.to ?
[{ from: range.to, to: previous.to }] :
[]),
...(previous.from < range.from
? [{ from: previous.from, to: range.from }]
: []),
...(previous.to > range.to
? [{ from: range.to, to: previous.to }]
: []),
]) {
await tx.tbankCandleCoverage.create({
data: { ...previous, ...part },
@@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client';
import type {
TbankBond,
TbankShare,
TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0;
@@ -66,8 +63,8 @@ export class CatalogDiscoveryRepository {
read(input: CatalogQuery) {
return this.prisma.$transaction(async (tx) => {
const alias = input.exactTicker ?
await tx.tbankInstrumentLookup.findUnique({
const alias = input.exactTicker
? await tx.tbankInstrumentLookup.findUnique({
where: {
provider_identifierType_identifier: {
provider,
@@ -75,17 +72,17 @@ export class CatalogDiscoveryRepository {
identifier: input.query,
},
},
}) :
null;
const discovery = input.exactTicker ?
alias ?
{
})
: null;
const discovery = input.exactTicker
? alias
? {
instrumentUids: alias.matchedUids,
fetchedAt: alias.fetchedAt,
dataRevision: alias.dataRevision,
} :
null :
await tx.tbankCatalogDiscovery.findUnique({
}
: null
: await tx.tbankCatalogDiscovery.findUnique({
where: {
provider_instrumentType_query: {
provider,
@@ -126,17 +123,29 @@ export class CatalogDiscoveryRepository {
}
readSources(inputs: CatalogSource[]) {
// Share identity predicates across UIDs instead of preparing one OR per row.
const groups = new Map<string, CatalogSource[]>();
for (const input of inputs) {
const key = JSON.stringify([
input.kind,
sourceProvider(input),
input.kind === 'detail' ? input.instrumentType : (input.rangeKey ?? ''),
]);
const group = groups.get(key);
if (group) group.push(input);
else groups.set(key, [input]);
}
return this.prisma.tbankInstrumentSource.findMany({
where: {
provider,
OR: inputs.map((input) => ({
instrumentUid: input.uid,
OR: [...groups.values()].map(([input, ...rest]) => ({
instrumentUid: { in: [input, ...rest].map((entry) => entry.uid) },
kind: input.kind,
source: sourceProvider(input),
rangeKey:
input.kind === 'detail' ?
input.instrumentType :
(input.rangeKey ?? ''),
input.kind === 'detail'
? input.instrumentType
: (input.rangeKey ?? ''),
})),
},
});
@@ -232,9 +241,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previousAlias?.matchedUids,
matchedUids,
) ?
UNCHANGED_REVISION_INCREMENT :
REVISION_INCREMENT,
)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -246,9 +255,9 @@ export class CatalogDiscoveryRepository {
source: sourceProvider(input),
kind: input.kind,
rangeKey:
input.kind === 'detail' ?
input.instrumentType :
(input.rangeKey ?? ''),
input.kind === 'detail'
? input.instrumentType
: (input.rangeKey ?? ''),
};
const previous = await tx.tbankInstrumentSource.findUnique({
where: { provider_instrumentUid_source_kind_rangeKey: key },
@@ -359,9 +368,9 @@ export class CatalogDiscoveryRepository {
instrumentKind: instrument.instrumentKind,
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
currency: instrument.currency,
brand: instrument.brand ?
{ logoName: instrument.brand.logoName } :
undefined,
brand: instrument.brand
? { logoName: instrument.brand.logoName }
: undefined,
}),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({
@@ -418,9 +427,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previous?.matchedUids,
instrumentUids,
) ?
UNCHANGED_REVISION_INCREMENT :
REVISION_INCREMENT,
)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -444,9 +453,9 @@ export class CatalogDiscoveryRepository {
increment: isDeepStrictEqual(
previous?.instrumentUids,
instrumentUids,
) ?
UNCHANGED_REVISION_INCREMENT :
REVISION_INCREMENT,
)
? UNCHANGED_REVISION_INCREMENT
: REVISION_INCREMENT,
},
},
});
@@ -462,7 +471,7 @@ export class CatalogDiscoveryRepository {
state: 'running',
connection: { invalidToken: true },
},
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
});
await tx.tbankSyncWork.updateMany({
where: {
@@ -472,7 +481,7 @@ export class CatalogDiscoveryRepository {
},
data: {
state: success ? 'complete' : 'failed',
errorCode: success ? null : 'TBANK_UNAVAILABLE',
errorCode: success ? null : TbankErrorCode.Unavailable,
},
});
});
@@ -480,9 +489,9 @@ export class CatalogDiscoveryRepository {
}
function sourceProvider(input: CatalogSource) {
return input.kind === 'yield' || input.kind === 'bond-events' ?
'moex' :
input.kind === 'rating' ?
'ncr' :
'tbank';
return input.kind === 'yield' || input.kind === 'bond-events'
? 'moex'
: input.kind === 'rating'
? 'ncr'
: 'tbank';
}
@@ -0,0 +1,19 @@
import { toSavedCandle } from './tbank-candle.mapper';
describe('T-Bank candle mapping', () => {
it('keeps signed nano precision and requires explicit completeness', () => {
const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } };
expect(toSavedCandle(input)).toEqual({
time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false,
});
expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true);
expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } });
});
it.each([
{ close: { units: '1' } },
{ time: { seconds: 'invalid' }, close: { units: '1' } },
{ time: { seconds: '1767225600' } },
])('rejects missing or malformed source fields: %j', (input) => {
expect(() => toSavedCandle(input)).toThrow('Invalid candle response');
});
});
@@ -0,0 +1,18 @@
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
import type { SavedCandle } from '../domain/candle-coverage';
import { formatDecimal } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal';
import { timestamp } from '../application/instrument-market';
function toSavedCandle(candle: TbankCandle): SavedCandle {
const time = timestamp(candle.time);
if (!time || !candle.close) throw new Error('Invalid candle response');
return {
time,
close: formatDecimal(decimal(candle.close)),
isComplete: candle.isComplete === true,
};
}
export { toSavedCandle };
@@ -1,2 +0,0 @@
export { BondIncomeService } from './bonds/application/bond-income.service';
export { StockIncomeService } from './stocks/application/stock-income.service';
@@ -23,8 +23,8 @@ export class GetStockCompanyUseCase {
observe,
);
return {
data: result.values.instrument ?
{
data: result.values.instrument
? {
description: result.values.description ?? null,
metrics: metrics(
result.values.fundamentals ?? null,
@@ -32,8 +32,8 @@ export class GetStockCompanyUseCase {
),
unavailable: result.unavailable,
observedAt: result.observedAt,
} :
null,
}
: null,
meta: result.meta,
};
}
@@ -51,22 +51,22 @@ export function metrics(
][] = [
['marketCapitalization', 'money', null],
['peRatioTtm', 'ratio', 'TTM'],
...(bank ?
([
...(bank
? ([
['priceToBookTtm', 'ratio', 'TTM'],
['roe', 'percent', null],
] as typeof selection) :
([
] as typeof selection)
: ([
['revenueTtm', 'money', 'TTM'],
['ebitdaTtm', 'money', 'TTM'],
] as typeof selection)),
['netIncomeTtm', 'money', 'TTM'],
...(!bank ?
([
...(!bank
? ([
['totalDebtMrq', 'money', 'MRQ'],
['netDebtToEbitda', 'ratio', null],
] as typeof selection) :
[]),
] as typeof selection)
: []),
];
return selection.flatMap(([key, unit, period]) => {
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
@@ -74,8 +74,8 @@ export function metrics(
const currency = values.currency?.toUpperCase() || null;
return typeof value === 'number' &&
Number.isFinite(value) &&
(unit !== 'money' || currency) ?
[
(unit !== 'money' || currency)
? [
{
key,
value,
@@ -83,7 +83,7 @@ export function metrics(
period,
currency: unit === 'money' ? currency : null,
},
] :
[];
]
: [];
});
}
@@ -34,9 +34,9 @@ export class StockCatalogService {
data:
result.data?.map((item) => {
const short = stock(item);
const detail = item.detail ?
stock({ ...item.detail, uid: item.uid }) :
short;
const detail = item.detail
? stock({ ...item.detail, uid: item.uid })
: short;
const last = item.price;
return {
...detail,
@@ -44,12 +44,12 @@ export class StockCatalogService {
isin: detail.isin ?? short.isin,
apiTradeAvailable: short.apiTradeAvailable,
price:
last?.price && detail.currency ?
{
last?.price && detail.currency
? {
currency: detail.currency,
value: formatDecimal(decimal(last.price)),
} :
null,
}
: null,
priceAt: timestamp(last?.time),
};
}) ?? null,
@@ -64,9 +64,9 @@ export class StockCatalogService {
const result = await this.discovery.detail(userId, 'share', uid, observe);
return {
...result,
data: result.data ?
{ ...stock(result.data), slug: result.data.slug } :
null,
data: result.data
? { ...stock(result.data), slug: result.data.slug }
: null,
};
}
@@ -83,9 +83,9 @@ export class StockCatalogService {
);
return {
...result,
data: result.data ?
{ ...stock(result.data), slug: result.data.slug } :
null,
data: result.data
? { ...stock(result.data), slug: result.data.slug }
: null,
};
}
}
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import {
TbankAccessService,
type TbankDividend,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api';
import {
ForecastService,
@@ -69,18 +70,18 @@ export class StockIncomeService {
throw new NotFoundException();
}
const events = sources[1].payload?.dividends;
const position = portfolio.data ?
instrumentPosition(portfolio.data, uid, 'STOCK') :
null;
const projected = events ?
stockDividends(
const position = portfolio.data
? instrumentPosition(portfolio.data, uid, 'STOCK')
: null;
const projected = events
? stockDividends(
events,
position,
{ from, to, today },
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
!portfolio.data,
) :
undefined;
)
: undefined;
const result = await this.forecasts.result({
userId,
revision: connection.revision,
@@ -91,9 +92,13 @@ export class StockIncomeService {
candidate:
portfolio.data && sources[0].instrument ? projected : undefined,
});
return result.data || !projected ?
result :
{
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected
? result
: {
data: stockDividends(
events ?? [],
null,
@@ -117,9 +122,9 @@ export function stockDividends(
const quantity =
position &&
position.accounts.length &&
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
decimalFromString(position.quantity) :
null;
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
? decimalFromString(position.quantity)
: null;
const dividends: StockDividendDto[] = events
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
.map((event) => {
@@ -128,29 +133,29 @@ export function stockDividends(
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
const currency = event.dividendNet?.currency?.toUpperCase();
const perShare =
currency && event.dividendNet ?
{ currency, value: formatDecimal(decimal(event.dividendNet)) } :
null;
currency && event.dividendNet
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
: null;
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
const eventDate = paymentDate ?? recordDate;
const timing: StockDividendDto['timing'] = eventDate ?
eventDate >= today ?
'upcoming' :
'past' :
'undated';
const timing: StockDividendDto['timing'] = eventDate
? eventDate >= today
? 'upcoming'
: 'past'
: 'undated';
const estimatedAmount =
quantity &&
perShare &&
recordDate &&
recordDate >= today &&
timing === 'upcoming' ?
{
timing === 'upcoming'
? {
currency: perShare.currency,
value: formatDecimal(
multiply(quantity, decimalFromString(perShare.value)),
),
} :
null;
}
: null;
return {
perShare,
recordDate,
@@ -44,9 +44,9 @@ export class StockMarketService {
observe,
);
return {
data: result.values.instrument ?
stockQuote(result.values, result.unavailable, result.observedAt) :
null,
data: result.values.instrument
? stockQuote(result.values, result.unavailable, result.observedAt)
: null,
meta: result.meta,
};
}
@@ -75,20 +75,20 @@ export function stockQuote(
const priceAt = timestamp(last?.time);
const currency = share?.currency?.toUpperCase();
const price =
last?.price && currency ?
{ currency, value: formatDecimal(decimal(last.price)) } :
null;
last?.price && currency
? { currency, value: formatDecimal(decimal(last.price)) }
: null;
const previousPrice =
previous?.close && currency ?
{ currency, value: formatDecimal(decimal(previous.close)) } :
null;
previous?.close && currency
? { currency, value: formatDecimal(decimal(previous.close)) }
: null;
const difference =
price && previousPrice ?
subtract(
price && previousPrice
? subtract(
decimalFromString(price.value),
decimalFromString(previousPrice.value),
) :
null;
)
: null;
return {
price,
priceAt,
@@ -96,21 +96,21 @@ export function stockQuote(
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
previousClose: previousPrice,
change:
difference && currency ?
{ currency, value: formatDecimal(difference) } :
null,
difference && currency
? { currency, value: formatDecimal(difference) }
: null,
changePercent:
difference &&
previousPrice &&
decimalFromString(previousPrice.value).value > ZERO_AMOUNT ?
formatDecimal(
decimalFromString(previousPrice.value).value > ZERO_AMOUNT
? formatDecimal(
divide(
multiply(difference, decimalFromString('100')),
decimalFromString(previousPrice.value),
CHANGE_PERCENT_DECIMAL_PLACES,
),
) :
null,
)
: null,
unavailable,
observedAt,
};
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
export const syncStateValues = ['idle', 'running', 'failed'] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
@ApiProperty()
declare dataRevision: string;
@ApiProperty()
declare stateVersion: string;
@ApiPropertyOptional({ type: String })
unavailableReason?: string;
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
const stale = investmentResourceMeta(input);
expect(stale.freshness).toBe('stale');
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
expect(
investmentResourceMeta({
...input,
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
});
expect(terminal.sync).toEqual(failed.sync);
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
expect(
investmentResourceMeta({
...input,
@@ -1,5 +1,3 @@
import { createHash } from 'node:crypto';
import { InvestmentResourceMetaDto } from './investment-resource.dto';
export function investmentResourceMeta(input: {
@@ -16,11 +14,11 @@ export function investmentResourceMeta(input: {
const ownSync = {
id: input.work?.id ?? null,
state:
input.work?.state === 'running' ?
('running' as const) :
input.work?.state === 'failed' ?
('failed' as const) :
('idle' as const),
input.work?.state === 'running'
? ('running' as const)
: input.work?.state === 'failed'
? ('failed' as const)
: ('idle' as const),
errorCode: input.work?.errorCode ?? null,
};
const states = [
@@ -31,14 +29,13 @@ export function investmentResourceMeta(input: {
];
const sync =
states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'queued') ??
states.find((state) => state.state === 'failed') ??
ownSync;
const sourceFreshness = input.fetchedAt ?
Date.now() - input.fetchedAt.valueOf() < input.freshForMs ?
('fresh' as const) :
('stale' as const) :
('unknown' as const);
const sourceFreshness = input.fetchedAt
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
? ('fresh' as const)
: ('stale' as const)
: ('unknown' as const);
const freshness = [
sourceFreshness,
...(input.dependencies ?? [])
@@ -56,34 +53,20 @@ export function investmentResourceMeta(input: {
.sort();
const meta = {
resourceKey: input.resourceKey,
...(input.unavailableReason ?
{ unavailableReason: input.unavailableReason } :
{}),
...(input.unavailableReason
? { unavailableReason: input.unavailableReason }
: {}),
dataRevision: input.dataRevision,
availability: input.availability,
freshness: freshness.includes('unknown') ?
('unknown' as const) :
freshness.includes('stale') ?
('stale' as const) :
('fresh' as const),
freshness: freshness.includes('unknown')
? ('unknown' as const)
: freshness.includes('stale')
? ('stale' as const)
: ('fresh' as const),
lastSuccessfulAt: timestamps[0] ?? null,
sync,
...(input.coverage ? { coverage: input.coverage } : {}),
...(input.dependencies ? { dependencies: input.dependencies } : {}),
};
return {
...meta,
stateVersion: createHash('sha256')
.update(
JSON.stringify({
...meta,
// The requested window is identified by resourceKey. Its moving "now"
// boundary is not a change to saved coverage between status and GET.
coverage: meta.coverage ?
{ ...meta.coverage, requested: undefined } :
undefined,
}),
)
.digest('base64url'),
};
return meta;
}
+43 -62
View File
@@ -84,16 +84,16 @@ export function planInvestmentScreen(
const unavailable: { reason: string; resourceKey: string }[] = [];
const operations = (from: Date, to: Date, instrumentUid?: string) => {
const targets = accounts.flatMap((account) =>
from < to ?
[
from < to
? [
{
accountId: account.accountId,
from,
to,
...(instrumentUid ? { instrumentUid } : {}),
},
] :
[],
]
: [],
);
sources.push(
...operationIntervals(targets).map((range) => ({
@@ -112,12 +112,12 @@ export function planInvestmentScreen(
})),
);
const knownOpening =
accounts.length && accounts.every((account) => account.openedOn) ?
new Date(
accounts.length && accounts.every((account) => account.openedOn)
? new Date(
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
) :
undefined;
)
: undefined;
if (!context.membershipKnown) return { sources, unavailable, now };
if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
@@ -146,9 +146,9 @@ export function planInvestmentScreen(
);
for (const range of [
period.selected,
...(screen.screen === 'income' ?
[period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] :
[]),
...(screen.screen === 'income'
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
: []),
]) {
const bounds = dateOnlyBounds(range, now);
operations(bounds.from, bounds.to);
@@ -216,11 +216,11 @@ export function planInvestmentScreen(
}
const range =
screen.candleRange ??
(period === 'all' ?
instrument.historyFrom ?
{ from: instrument.historyFrom, to: now } :
null :
{ from, to: now });
(period === 'all'
? instrument.historyFrom
? { from: instrument.historyFrom, to: now }
: null
: { from, to: now });
if (range) {
sources.push({
...identity,
@@ -237,17 +237,17 @@ export function planInvestmentScreen(
}
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
const day =
screen.screen === 'stock' || screen.screen === 'bond' ?
moscowDate(now) :
now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
screen.screen === 'stock' || screen.screen === 'bond'
? moscowDate(now)
: now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const from =
screen.screen === 'events' && screen.from ?
new Date(`${screen.from}T00:00:00Z`) :
new Date(`${day}T00:00:00Z`);
screen.screen === 'events' && screen.from
? new Date(`${screen.from}T00:00:00Z`)
: new Date(`${day}T00:00:00Z`);
const to =
screen.screen === 'events' && screen.to ?
new Date(`${screen.to}T00:00:00Z`) :
new Date(from);
screen.screen === 'events' && screen.to
? new Date(`${screen.to}T00:00:00Z`)
: new Date(from);
if (
!(screen.screen === 'events' && screen.to) &&
screen.screen !== 'stock' &&
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
priority: 'screen',
});
}
if (
!force &&
['account', 'events', 'income', 'operations'].includes(screen.screen)
) {
if (instrument.historyFrom) {
sources.push({
...identity,
kind: 'candles',
from: instrument.historyFrom,
to: now,
priority: 'history',
});
} else {
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
}
}
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
sources.push(
@@ -309,9 +290,9 @@ export function accountHistory(
): OperationInterval[] {
return accounts.flatMap((account) => {
const from = new Date(
account.openedOn ?
`${account.openedOn}T00:00:00Z` :
'1970-01-01T00:00:00Z',
account.openedOn
? `${account.openedOn}T00:00:00Z`
: '1970-01-01T00:00:00Z',
);
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
});
@@ -324,22 +305,22 @@ export function ledgerRange(
accounts: Account[],
now: Date,
) {
const opening = accounts.every((account) => account.openedOn) ?
const opening = accounts.every((account) => account.openedOn)
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
accounts.map((account) => account.openedOn ?? '').sort()[0] :
undefined;
const from = fromValue ?
new Date(fromValue) :
instrumentUid ?
new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') :
new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
const end = toValue ?
new Date(
/^\d{4}-\d{2}-\d{2}$/.test(toValue) ?
`${toValue}T23:59:59.999Z` :
toValue,
) :
now;
? accounts.map((account) => account.openedOn ?? '').sort()[0]
: undefined;
const from = fromValue
? new Date(fromValue)
: instrumentUid
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
: new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
const end = toValue
? new Date(
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
? `${toValue}T23:59:59.999Z`
: toValue,
)
: now;
return {
from,
to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
@@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import {
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
import { accountHistory } from '../../investment-sync-plan';
import {
type OperationInterval,
@@ -82,24 +81,24 @@ export class BrokerageOperationsSyncService {
...intervals.map((interval) => interval.to.valueOf()),
);
const targets = intervals.map((interval) =>
options.movingEnd && interval.to.valueOf() === movingTo ?
{ ...interval, to: now } :
interval,
options.movingEnd && interval.to.valueOf() === movingTo
? { ...interval, to: now }
: interval,
);
const plan = planOperationRead(targets, coverage, now, options);
const complete = plan.intervals.every(
(interval) => interval.availability === 'complete',
);
const handle =
!options.observe && plan.load.length ?
await this.request(
!options.observe && plan.load.length
? await this.request(
userId,
plan.load,
complete || Boolean(options.force),
'screen',
connection.revision,
) :
null;
)
: null;
if (!options.observe && !options.force && options.history) {
const accountIds = new Set(
intervals.map((interval) => interval.accountId),
@@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService {
this.logger.event(
'error',
'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
);
}
const next = pending ? await this.repository.pending(attempt) : null;
@@ -267,12 +266,12 @@ export class BrokerageOperationsSyncService {
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS,
),
},
...(interval.instrumentUid ?
{ instrumentId: interval.instrumentUid } :
{}),
...(interval.instrumentUid
? { instrumentId: interval.instrumentUid }
: {}),
cursor,
limit: operationsPageSize,
state: 'OPERATION_STATE_EXECUTED',
state: TbankOperationState.Executed,
});
if (
!(await this.repository.stagePage(attempt, interval, cursor, response))
@@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService {
} catch (error) {
await this.repository.fail(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
);
return false;
}
@@ -17,7 +17,9 @@ import {
ledgerRange,
planInvestmentScreen,
} from '../../investment-sync-plan';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
confirmedOperationRanges,
operationIntervalsOverlap,
operationsSyncPriority,
planOperationRead,
@@ -86,13 +88,13 @@ export class BrokerageOperationsService {
const filterKey = JSON.stringify([
accountId,
from.toISOString(),
query.to ?
new Date(
/^\d{4}-\d{2}-\d{2}$/.test(query.to) ?
`${query.to}T23:59:59.999Z` :
query.to,
).toISOString() :
'now',
query.to
? new Date(
/^\d{4}-\d{2}-\d{2}$/.test(query.to)
? `${query.to}T23:59:59.999Z`
: query.to,
).toISOString()
: 'now',
query.instrumentUid ?? null,
categories,
limit,
@@ -129,9 +131,9 @@ export class BrokerageOperationsService {
source.priority === 'screen' &&
source.accountId &&
source.from &&
source.to ?
[{ accountId: source.accountId, from: source.from, to: source.to }] :
[],
source.to
? [{ accountId: source.accountId, from: source.from, to: source.to }]
: [],
);
if (query.sync !== 'observe') {
const discovery = await this.membership.request(
@@ -159,9 +161,9 @@ export class BrokerageOperationsService {
accountId === 'all' || account.accountId === accountId,
)
.flatMap((account) =>
currentRange.from < currentRange.to ?
[{ accountId: account.accountId, ...currentRange }] :
[],
currentRange.from < currentRange.to
? [{ accountId: account.accountId, ...currentRange }]
: [],
);
await this.sync.readPlan(userId, targets, {
movingEnd,
@@ -181,36 +183,21 @@ export class BrokerageOperationsService {
const saved = await this.repository.readPage(readInput);
const accounts = saved?.connection.accounts ?? [];
const targets = accounts.flatMap((account) =>
from < to ?
[
from < to
? [
{
accountId: account.accountId,
from,
to,
instrumentUid: query.instrumentUid,
},
] :
[],
]
: [],
);
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
movingEnd,
});
const confirmed = plan.intervals.flatMap((target) =>
(saved?.coverage ?? [])
.filter(
(row) =>
row.accountId === target.accountId &&
(!row.instrumentUid ||
row.instrumentUid === target.instrumentUid) &&
row.from < target.to &&
target.from < row.to,
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
const missing = uncoveredOperationIntervals(
plan.intervals,
confirmed.map((range) => ({
@@ -296,17 +283,17 @@ export class BrokerageOperationsService {
const meta = investmentResourceMeta({
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
dataRevision: saved?.binding ?? 'missing',
availability: !available ?
'missing' :
complete ?
'complete' :
confirmed.length ?
'partial' :
'missing',
availability: !available
? 'missing'
: complete
? 'complete'
: confirmed.length
? 'partial'
: 'missing',
fetchedAt: lastSuccessfulAt,
freshForMs: plan.intervals.some((interval) => interval.stale) ?
STALE_FRESHNESS_MS :
Number.POSITIVE_INFINITY,
freshForMs: plan.intervals.some((interval) => interval.stale)
? STALE_FRESHNESS_MS
: Number.POSITIVE_INFINITY,
work: work ?? saved?.membershipWork ?? null,
dependencies: [membershipMeta, ...metadataDependencies],
coverage: {
@@ -320,8 +307,8 @@ export class BrokerageOperationsService {
(interval) =>
interval.accountId === account.accountId && interval.asOf,
),
) ?
new Date(
)
? new Date(
Math.min(
...accounts.map((account) =>
Math.max(
@@ -334,42 +321,42 @@ export class BrokerageOperationsService {
),
),
),
).toISOString() :
null,
).toISOString()
: null,
historyBoundary: knownBoundary ? 'known' : 'unknown',
historyState: saved?.work.some(
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
) ?
'loading' :
saved?.work.some(
)
? 'loading'
: saved?.work.some(
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
) ?
'failed' :
knownBoundary &&
history.intervals.every(
(interval) => interval.availability === 'complete',
) ?
'complete' :
saved?.coverage.length ?
'partial' :
'missing',
)
? 'failed'
: knownBoundary &&
history.intervals.every(
(interval) => interval.availability === 'complete',
)
? 'complete'
: saved?.coverage.length
? 'partial'
: 'missing',
},
});
const last = rows.at(LAST_ROW_INDEX);
const hasNext = Boolean(saved && saved.rows.length > limit);
return {
data:
meta.availability === 'missing' ?
null :
{
meta.availability === 'missing'
? null
: {
accountId,
items: rows.map((row) => ({
...mapBrokerageOperation(
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all' ?
{
...(accountId === 'all'
? {
account: {
id: row.accountId,
name:
@@ -377,8 +364,8 @@ export class BrokerageOperationsService {
(account) => account.accountId === row.accountId,
)?.name ?? row.accountId,
},
} :
{}),
}
: {}),
logoName:
instruments
.get(metadataKey(row.payload as TbankOperationItem))
@@ -386,16 +373,16 @@ export class BrokerageOperationsService {
})),
hasNext,
nextCursor:
hasNext && last && saved ?
this.encodeCursor({
hasNext && last && saved
? this.encodeCursor({
binding: saved.binding,
movingEnd,
to: to.toISOString(),
at: last.occurredAt.toISOString(),
accountId: last.accountId,
id: last.brokerOperationId,
}) :
null,
})
: null,
observedAt: now.toISOString(),
},
meta,
@@ -442,7 +429,7 @@ export class BrokerageOperationsService {
}
return cursor as LocalOperationsCursor;
} catch {
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
}
@@ -543,30 +530,18 @@ export class BrokerageOperationsService {
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
(complete ? saved?.connection.membershipAt : null) ??
null;
const confirmed = plan.intervals.flatMap((target) =>
sources
.filter(
(row) =>
row.accountId === target.accountId &&
operationIntervalsOverlap(row, target),
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
const confirmed = confirmedOperationRanges(plan.intervals, sources);
const meta = investmentResourceMeta({
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
dataRevision: saved?.binding ?? 'missing',
availability: !known ?
'missing' :
complete ?
'complete' :
sources.length ?
'partial' :
'missing',
availability: !known
? 'missing'
: complete
? 'complete'
: sources.length
? 'partial'
: 'missing',
fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work,
@@ -579,8 +554,8 @@ export class BrokerageOperationsService {
confirmed,
).map(serialize),
asOf:
complete && targets.length ?
new Date(
complete && targets.length
? new Date(
Math.min(
...targets.map((target) =>
Math.max(
@@ -595,24 +570,24 @@ export class BrokerageOperationsService {
),
),
),
).toISOString() :
null,
).toISOString()
: null,
historyBoundary: (saved?.connection.accounts ?? []).every(
(account) => account.openedOn,
) ?
'known' :
'unknown',
historyState: complete ?
'complete' :
sources.length ?
'partial' :
'missing',
)
? 'known'
: 'unknown',
historyState: complete
? 'complete'
: sources.length
? 'partial'
: 'missing',
},
});
return {
meta,
items: complete ?
(saved?.rows ?? [])
items: complete
? (saved?.rows ?? [])
.filter((row) =>
plan.intervals.some(
(interval) =>
@@ -626,8 +601,8 @@ export class BrokerageOperationsService {
row.payload as TbankOperationItem,
row.occurredAt,
),
...(accountId === 'all' ?
{
...(accountId === 'all'
? {
account: {
id: row.accountId,
name:
@@ -635,10 +610,10 @@ export class BrokerageOperationsService {
(account) => account.accountId === row.accountId,
)?.name ?? row.accountId,
},
} :
{}),
})) :
null,
}
: {}),
}))
: null,
};
});
}
@@ -0,0 +1,3 @@
export enum OperationErrorCode {
InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR',
}
@@ -1,4 +1,30 @@
import { operationIntervals, planOperationRead } from './operations-sync-plan';
import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan';
describe('confirmed operation range projection', () => {
it('clips overlaps in source order without merging and respects account and instrument scope', () => {
const day = (value: number) => new Date(Date.UTC(2026, 0, value));
const target = { accountId: 'a', from: day(2), to: day(5) };
const sources = [
{ ...target, from: day(3), to: day(6) },
{ ...target, from: day(1), to: day(4) },
{ ...target, instrumentUid: 'uid' },
{ ...target, accountId: 'other' },
{ ...target, from: day(5), to: day(6) },
{ ...target, from: day(1), to: day(2) },
];
const before = structuredClone(sources);
expect(confirmedOperationRanges([target], sources)).toEqual([
{ ...target, from: day(3) },
{ ...target, to: day(4) },
]);
expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([
{ ...target, from: day(3) },
{ ...target, to: day(4) },
target,
]);
expect(sources).toEqual(before);
});
});
describe('operations calendar windows', () => {
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
@@ -93,12 +93,12 @@ export function uncoveredOperationIntervals(
return [fragment];
}
return [
...(fragment.from < covered.from ?
[{ ...fragment, to: covered.from }] :
[]),
...(covered.to < fragment.to ?
[{ ...fragment, from: covered.to }] :
[]),
...(fragment.from < covered.from
? [{ ...fragment, to: covered.from }]
: []),
...(covered.to < fragment.to
? [{ ...fragment, from: covered.to }]
: []),
];
});
}
@@ -106,6 +106,24 @@ export function uncoveredOperationIntervals(
});
}
export function confirmedOperationRanges(
targets: OperationInterval[],
coverage: OperationInterval[],
): Omit<OperationInterval, 'instrumentUid'>[] {
return targets.flatMap((target) => coverage
.filter((row) =>
row.accountId === target.accountId &&
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
row.from < target.to && target.from < row.to,
)
.map((row) => ({
accountId: target.accountId,
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
})),
);
}
export type OperationCoverage = OperationInterval & {
asOf: Date;
fetchedAt: Date;
@@ -176,20 +194,20 @@ export function planOperationRead(
// Completeness is bounded by the observed end, never by the new wall clock.
return {
...effective,
availability: complete ?
('complete' as const) :
sources.length ?
('partial' as const) :
('missing' as const),
availability: complete
? ('complete' as const)
: sources.length
? ('partial' as const)
: ('missing' as const),
stale,
asOf: sources.length ?
new Date(
asOf: sources.length
? new Date(
Math.min(
effective.to.valueOf(),
Math.max(...sources.map((row) => row.asOf.valueOf())),
),
) :
null,
)
: null,
};
});
return { intervals, load: operationIntervals(load) };
@@ -1,10 +1,8 @@
import { formatDecimal } from '../../../../common/decimal';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankMoney,
TbankOperationItem,
} from '../../../../integrations/tbank/tbank.api';
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
import {
BrokerageOperationDto,
OperationCategory,
@@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0;
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
OPERATION_TYPE_SELL: 'trade',
OPERATION_TYPE_SELL_CARD: 'trade',
OPERATION_TYPE_BUY_MARGIN: 'trade',
OPERATION_TYPE_SELL_MARGIN: 'trade',
OPERATION_TYPE_COUPON: 'income',
OPERATION_TYPE_DIVIDEND: 'income',
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
OPERATION_TYPE_DIV_EXT: 'income',
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
OPERATION_TYPE_BOND_TAX: 'tax',
OPERATION_TYPE_TAX: 'tax',
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
OPERATION_TYPE_SERVICE_FEE: 'fee',
OPERATION_TYPE_MARGIN_FEE: 'fee',
OPERATION_TYPE_BROKER_FEE: 'fee',
OPERATION_TYPE_SUCCESS_FEE: 'fee',
OPERATION_TYPE_INPUT: 'transfer',
OPERATION_TYPE_OUTPUT: 'transfer',
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
[TbankOperationType.Buy]: 'trade',
[TbankOperationType.BuyCard]: 'trade',
[TbankOperationType.Sell]: 'trade',
[TbankOperationType.SellCard]: 'trade',
[TbankOperationType.BuyMargin]: 'trade',
[TbankOperationType.SellMargin]: 'trade',
[TbankOperationType.Coupon]: 'income',
[TbankOperationType.Dividend]: 'income',
[TbankOperationType.DividendTransfer]: 'income',
[TbankOperationType.DivExt]: 'income',
[TbankOperationType.TransIisBs]: 'transfer',
[TbankOperationType.TransBsBs]: 'transfer',
[TbankOperationType.BondTax]: 'tax',
[TbankOperationType.Tax]: 'tax',
[TbankOperationType.DividendTax]: 'tax',
[TbankOperationType.ServiceFee]: 'fee',
[TbankOperationType.MarginFee]: 'fee',
[TbankOperationType.BrokerFee]: 'fee',
[TbankOperationType.SuccessFee]: 'fee',
[TbankOperationType.Input]: 'transfer',
[TbankOperationType.Output]: 'transfer',
[TbankOperationType.InputSecurities]: 'transfer',
[TbankOperationType.OutputSecurities]: 'transfer',
};
export function operationCategory(type: string | undefined): OperationCategory {
@@ -51,7 +49,7 @@ export function mapBrokerageOperation(
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: occurredAt.toISOString(),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
type: operation.type ?? TbankOperationType.Unspecified,
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
figi: text(operation.figi),
@@ -61,14 +59,14 @@ export function mapBrokerageOperation(
logoName: null,
payment: money(operation.payment),
quantity:
(operationCategory(operation.type) === 'trade' ?
operation.quantityDone :
operation.quantity) === undefined ?
null :
String(
operationCategory(operation.type) === 'trade' ?
operation.quantityDone :
operation.quantity,
(operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity) === undefined
? null
: String(
operationCategory(operation.type) === 'trade'
? operation.quantityDone
: operation.quantity,
),
price: money(operation.price),
accruedInterest: money(operation.accruedInt),
@@ -82,9 +80,9 @@ function text(value: string | undefined): null | string {
function money(value: TbankMoney | undefined) {
const currency = value?.currency?.trim().toUpperCase();
return currency && value ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
export function operationTimestamp(
@@ -12,12 +12,15 @@ import type {
TbankOperationsByCursorResponse,
} from '../../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
dateOnlyBounds,
DateOnlyRange,
moscowDate,
} from '../../investment-calendar';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
type OperationInterval,
operationIntervals,
@@ -110,9 +113,9 @@ export class BrokerageOperationsRepository {
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
const oldest = accounts.map((account) => account.openedOn).sort()[0];
const openedAt =
oldest && accounts.every((account) => account.openedOn) ?
new Date(`${oldest}T00:00:00+03:00`) :
undefined;
oldest && accounts.every((account) => account.openedOn)
? new Date(`${oldest}T00:00:00+03:00`)
: undefined;
const periods = input.periods?.(openedAt).map((period) => {
const now = new Date();
const bounds = dateOnlyBounds(period, now);
@@ -137,7 +140,7 @@ export class BrokerageOperationsRepository {
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding) {
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
const scope = {
userId: input.userId,
@@ -176,21 +179,21 @@ export class BrokerageOperationsRepository {
const rows = await tx.tbankOperation.findMany({
where: {
...scope,
...(periods ?
{
...(periods
? {
OR: periods.map((period) => ({
occurredAt: { gte: period.from, lt: period.to },
})),
} :
{ occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid ?
{ instrumentUid: input.instrumentUid } :
{}),
...(input.categories.length ?
{ category: { in: input.categories } } :
{}),
...(after ?
{
}
: { occurredAt: { gte: input.from, lt: input.to } }),
...(input.instrumentUid
? { instrumentUid: input.instrumentUid }
: {}),
...(input.categories.length
? { category: { in: input.categories } }
: {}),
...(after
? {
OR: [
{ occurredAt: { lt: new Date(after.at) } },
{
@@ -203,8 +206,8 @@ export class BrokerageOperationsRepository {
brokerOperationId: { gt: after.id },
},
],
} :
{}),
}
: {}),
},
orderBy: [
{ occurredAt: 'desc' },
@@ -305,9 +308,9 @@ export class BrokerageOperationsRepository {
},
data: {
historyFrom: new Date(
account.openedOn ?
`${account.openedOn}T00:00:00Z` :
'1970-01-01T00:00:00Z',
account.openedOn
? `${account.openedOn}T00:00:00Z`
: '1970-01-01T00:00:00Z',
),
historyBoundary: account.openedOn ? 'known' : 'unknown',
},
@@ -383,58 +386,21 @@ export class BrokerageOperationsRepository {
) {
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
const additions =
previous.scopeKey === scopeKey ?
[] :
uncoveredOperationIntervals(intervals, pending);
previous.scopeKey === scopeKey
? []
: uncoveredOperationIntervals(intervals, pending);
const work = await tx.tbankSyncWork.update({
where: { id: previous.id },
data: {
intent: {
atomic: previousIntent?.atomic ?? publicationAtomic,
atomicIntervals: previousIntent?.atomic ?
[
...new Set([
...(previousIntent.atomicIntervals ??
previousIntent.intervals),
...additions.map(intervalKey),
]),
] :
[],
screenIntervals: [
...new Set([
...(previousIntent?.screenIntervals ??
(previous.priority === operationsSyncPriority.screen ?
(previousIntent?.intervals ?? []) :
[])),
...(priority === operationsSyncPriority.screen ?
[...pending, ...additions]
.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
)
.map(intervalKey) :
[]),
]),
],
intervals: (priority < previous.priority ?
[
...pending.filter((item) =>
intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
...additions,
...pending.filter(
(item) =>
!intervals.some((target) =>
operationIntervalsOverlap(item, target),
),
),
] :
[...pending, ...additions]
).map(intervalKey),
},
intent: extendRunningIntent({
previous: previousIntent,
publicationAtomic,
pending,
additions,
intervals,
priority,
previousPriority: previous.priority,
}),
priority: Math.min(priority, previous.priority),
},
});
@@ -444,9 +410,9 @@ export class BrokerageOperationsRepository {
previous &&
previous.state !== 'complete' &&
previous.state !== 'superseded';
const retained = recovering ?
(previousIntent?.intervals.map(decodeInterval) ?? []) :
[];
const retained = recovering
? (previousIntent?.intervals.map(decodeInterval) ?? [])
: [];
// A bridge between two active groups must fence both producers before
// either can publish a subset of the newly combined result.
for (const joined of touching) {
@@ -469,23 +435,23 @@ export class BrokerageOperationsRepository {
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
}
const targets =
recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ?
previous?.scopeKey === scopeKey ?
retained :
[...retained, ...uncoveredOperationIntervals(intervals, retained)] :
operationIntervals([...retained, ...intervals]);
recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP
? previous?.scopeKey === scopeKey
? retained
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
: operationIntervals([...retained, ...intervals]);
const atomicTargets = [
...(publicationAtomic ? intervals : []),
...(recovering && previousIntent?.atomic ?
(previousIntent.atomicIntervals ?? previousIntent.intervals).map(
...(recovering && previousIntent?.atomic
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
decodeInterval,
) :
[]),
)
: []),
...touching.flatMap((work) => {
const joined = work.intent as OperationsIntent;
return joined.atomic ?
(joined.atomicIntervals ?? joined.intervals).map(decodeInterval) :
[];
return joined.atomic
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
: [];
}),
];
const intent = {
@@ -506,9 +472,9 @@ export class BrokerageOperationsRepository {
)) ||
(
previousIntent?.screenIntervals ??
(previous?.priority === operationsSyncPriority.screen ?
(previousIntent?.intervals ?? []) :
[])
(previous?.priority === operationsSyncPriority.screen
? (previousIntent?.intervals ?? [])
: [])
).some((key) =>
operationIntervalsOverlap(target, decodeInterval(key)),
),
@@ -563,20 +529,20 @@ export class BrokerageOperationsRepository {
marker.payload as PageProgress,
]),
);
const group = intent.atomic ?
(intent.atomicIntervals ?? intent.intervals) :
intent.intervals;
const group = intent.atomic
? (intent.atomicIntervals ?? intent.intervals)
: intent.intervals;
const nextKey = group.find((key) => !progress.get(key)?.complete);
return {
priority: work.priority,
atomic: intent.atomic,
intervals: group.map(decodeInterval),
next: nextKey ?
{
next: nextKey
? {
interval: decodeInterval(nextKey),
cursor: progress.get(nextKey)?.cursor || undefined,
} :
null,
}
: null,
};
}
@@ -615,7 +581,7 @@ export class BrokerageOperationsRepository {
where: { id: attempt.id },
data: {
state: 'failed',
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable,
},
});
if (invalidToken) {
@@ -784,7 +750,7 @@ export class BrokerageOperationsRepository {
}
const rows = staged.filter((row) => {
const state = (row.payload as TbankOperationItem).state;
return !state || state === 'OPERATION_STATE_EXECUTED';
return !state || isTbankProtocolValue(state, TbankOperationState.Executed);
});
const scope = {
userId: attempt.userId,
@@ -795,9 +761,9 @@ export class BrokerageOperationsRepository {
where: {
...scope,
occurredAt: { gte: interval.from, lt: interval.to },
...(interval.instrumentUid ?
{ instrumentUid: interval.instrumentUid } :
{}),
...(interval.instrumentUid
? { instrumentUid: interval.instrumentUid }
: {}),
},
});
const incoming = new Set(rows.map((row) => row.itemKey));
@@ -902,9 +868,9 @@ export class BrokerageOperationsRepository {
intent.screenIntervals ??
(work.priority === operationsSyncPriority.screen ? intent.intervals : [])
).includes(key),
) ?
operationsSyncPriority.screen :
operationsSyncPriority.history,
)
? operationsSyncPriority.screen
: operationsSyncPriority.history,
progress: { remainingIntervals: remaining.length },
},
});
@@ -916,6 +882,40 @@ export class BrokerageOperationsRepository {
}
}
function extendRunningIntent(input: {
additions: OperationInterval[];
intervals: OperationInterval[];
pending: OperationInterval[];
previous: OperationsIntent | undefined;
previousPriority: number;
priority: number;
publicationAtomic: boolean;
}): OperationsIntent {
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
const overlapsRequested = (item: OperationInterval) =>
intervals.some((target) => operationIntervalsOverlap(item, target));
const retainedScreen = previous?.screenIntervals ??
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
const addedScreen = priority === operationsSyncPriority.screen
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
: [];
const ordered = priority < previousPriority
? [
...pending.filter(overlapsRequested),
...additions,
...pending.filter((item) => !overlapsRequested(item)),
]
: [...pending, ...additions];
return {
atomic: previous?.atomic ?? publicationAtomic,
atomicIntervals: previous?.atomic
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
: [],
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
intervals: ordered.map(intervalKey),
};
}
function intervalKey(interval: OperationInterval): string {
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
if (!(interval.from < interval.to)) {
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
ForecastRepository,
@@ -10,7 +9,6 @@ import {
} from '../infrastructure/forecast.repository';
const MISSING_DATA_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@Injectable()
export class ForecastService {
@@ -36,8 +34,8 @@ export class ForecastService {
...input,
portfolioProjection: input.portfolio.dataRevision,
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
dependency.resourceKey.startsWith('portfolio/') ?
[
dependency.resourceKey.startsWith('portfolio/')
? [
{
accountId: decodeURIComponent(
dependency.resourceKey.slice('portfolio/'.length),
@@ -46,21 +44,21 @@ export class ForecastService {
(dependency as InvestmentResourceMetaDto).dataRevision,
),
},
] :
[],
]
: [],
),
sources: input.sources.flatMap((source) =>
source.version ? [source.version] : [],
),
timestamps: dependencies.flatMap((dependency) =>
dependency.lastSuccessfulAt ?
[
dependency.lastSuccessfulAt
? [
{
resourceKey: dependency.resourceKey,
at: dependency.lastSuccessfulAt,
},
] :
[],
]
: [],
),
});
const timestamps = stored?.sourceTimestamps as
@@ -73,38 +71,20 @@ export class ForecastService {
dependencies
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
const payload = stored ? (stored.payload as T) : null;
const data =
payload &&
'positionIsStale' in payload &&
'positionAt' in payload &&
typeof payload.positionAt === 'string' ?
{
...payload,
positionIsStale: tbankSourceNeedsRefresh(
'portfolio',
new Date(payload.positionAt),
new Date(Date.now()),
),
} :
payload;
return {
data,
data: stored ? (stored.payload as T) : null,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
availability: stored ?
'complete' :
dependencies.some(
availability: stored
? 'complete'
: dependencies.some(
(dependency) => dependency.availability !== 'missing',
) ?
'partial' :
'missing',
)
? 'partial'
: 'missing',
fetchedAt: first ? new Date(first) : null,
freshForMs:
data && 'positionIsStale' in data && data.positionIsStale === true ?
STALE_FRESHNESS_MS :
Number.POSITIVE_INFINITY,
freshForMs: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null,
dependencies,
}),
@@ -1,4 +1,3 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { setImmediate as immediate } from 'node:timers/promises';
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
service = new PortfolioService(
access as never,
tbank as never,
{ set: jest.fn() } as never,
metadata as never,
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
);
access.local.mockResolvedValue({
revision: 'private',
@@ -129,6 +126,43 @@ describe('persistent portfolio projection', () => {
]);
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
});
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({ positions: [
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
{
quantity: { units: '1' },
currentPrice: { currency: 'USD', units: '3' },
averagePositionPrice: { currency: 'RUB', units: '2' },
},
] });
tbank.getPositions.mockResolvedValue({ money: [
{ currency: 'rub', units: '2' },
{ currency: 'RUB', units: '-2' },
{ units: '99' },
{ currency: 'EUR', units: '1' },
] });
await service.request('user', 'private', 'a');
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
await immediate();
}
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.cashBalances).toEqual([
{ currency: 'RUB', value: '0' },
{ currency: 'EUR', value: '1' },
]);
expect(snapshot.totalsByCurrency).toEqual([
{ currency: 'EUR', value: '1' },
{ currency: 'USD', value: '3' },
]);
expect(snapshot.positions[0].currentValue).toBeNull();
expect(snapshot.positions[1].absoluteReturn).toBeNull();
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
});
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
const snapshot: PortfolioDto = {
lastSuccessfulAt: membershipAt.toISOString(),
@@ -184,15 +218,4 @@ describe('persistent portfolio projection', () => {
expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled();
});
it('keeps pending data unavailable to internal financial consumers', async () => {
repository.read.mockResolvedValue({
membershipAt: null,
membershipRevision: 0,
accounts: [],
work: [],
});
await expect(service.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
});
@@ -1,34 +1,22 @@
import {
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
subtract,
} from '../../../../common/decimal';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MoneyDto } from '../../../../common/money.dto';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
InstrumentMetadataService,
type MetadataIdentity,
metadataKey,
TbankAccessService,
TbankClient,
type TbankInstrument,
TbankMembershipService,
TbankMoney,
type TbankMoney,
type TbankPortfolio,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
@@ -37,46 +25,29 @@ import {
PortfolioRepository,
portfolioWorkKey,
} from '../infrastructure/portfolio.repository';
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
import {
PortfolioDto,
PortfolioPositionDto,
} from '../presentation/portfolio.dto';
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
import { PortfolioDto } from '../presentation/portfolio.dto';
const MISSING_DATA_REVISION = 0;
const membershipFreshForMs = tbankSourceTtlMs.membership;
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
@Injectable()
export class PortfolioService {
private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
private readonly candles: CandleService,
) {}
async saved(userId: string, accountId: string, observe = false) {
this.requestContext.set({ userId, brokerageAccountId: accountId });
const connection = await this.access.local(userId, accountId);
if (!observe) {
const discovery = await this.membership.request(
@@ -145,28 +116,6 @@ export class PortfolioService {
}
}
}
if (!observe) {
void this.candles
.prefetch(
userId,
connection.revision,
(saved?.accounts ?? [])
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap(
(account) =>
(
account.portfolio?.payload as unknown as
PortfolioDto | undefined
)?.positions ?? [],
),
)
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
}
const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -186,15 +135,15 @@ export class PortfolioService {
name: account.name,
type: account.type,
openedOn: account.openedOn,
snapshot: account.portfolio ?
{
snapshot: account.portfolio
? {
...(account.portfolio.payload as unknown as PortfolioDto),
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
isStale:
Date.now() - account.portfolio.fetchedAt.valueOf() >=
portfolioFreshForMs,
} :
null,
}
: null,
meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION),
@@ -220,8 +169,8 @@ export class PortfolioService {
);
const metadataDependencies = [...metadata.entries()].flatMap(
([key, source]) =>
source.complete && source.data ?
[
source.complete && source.data
? [
investmentResourceMeta({
resourceKey: `instrument/metadata/${key}`,
dataRevision: source.dataRevision,
@@ -230,20 +179,20 @@ export class PortfolioService {
freshForMs: tbankSourceTtlMs.metadata,
work: null,
}),
] :
[],
]
: [],
);
const portfolios = saved.accounts.flatMap((account) =>
account.snapshot ?
[
account.snapshot
? [
{
...account.snapshot,
positions: account.snapshot.positions.map((position) => {
const shared = metadata.get(metadataKey(position))?.data;
return {
...position,
...(shared ?
{
...(shared
? {
displayName:
shared.name?.trim() ||
shared.ticker?.trim() ||
@@ -252,29 +201,29 @@ export class PortfolioService {
isin: shared.isin?.trim() || position.isin,
logoName:
shared.brand?.logoName?.trim() || position.logoName,
} :
{}),
...(accountId === 'all' ?
{ account: { id: account.id, name: account.name } } :
{}),
}
: {}),
...(accountId === 'all'
? { account: { id: account.id, name: account.name } }
: {}),
};
}),
},
] :
[],
]
: [],
);
const lastSuccessfulAt =
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
saved.membership.lastSuccessfulAt;
const data =
missing || !lastSuccessfulAt ?
null :
{
missing || !lastSuccessfulAt
? null
: {
lastSuccessfulAt,
isStale: portfolios.length ?
portfolios.some((portfolio) => portfolio.isStale) :
saved.membership.freshness !== 'fresh',
isStale: portfolios.length
? portfolios.some((portfolio) => portfolio.isStale)
: saved.membership.freshness !== 'fresh',
totalsByCurrency: sumMoneyByCurrency(
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
),
@@ -306,15 +255,15 @@ export class PortfolioService {
]),
metadataDependencies.map((dependency) => dependency.dataRevision),
]),
availability: missing ?
portfolios.length ?
'partial' :
'missing' :
'complete',
availability: missing
? portfolios.length
? 'partial'
: 'missing'
: 'complete',
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
freshForMs: portfolios.length ?
portfolioFreshForMs :
membershipFreshForMs,
freshForMs: portfolios.length
? portfolioFreshForMs
: membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null,
dependencies: [
saved.membership,
@@ -341,7 +290,7 @@ export class PortfolioService {
revision,
accountId,
this.processRunId,
this.active,
this.pending,
force,
);
if (!requested) return null;
@@ -352,7 +301,6 @@ export class PortfolioService {
};
}
const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => {
const attempt = { id, attemptId, userId, revision };
@@ -376,18 +324,11 @@ export class PortfolioService {
for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position);
}
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
}
} catch (error) {
if (
error instanceof Error &&
error.message === 'INVALID_TBANK_TOKEN'
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)
) {
await this.access.rejectMembershipToken(attempt);
} else await this.repository.fail(attempt);
@@ -399,7 +340,6 @@ export class PortfolioService {
}),
)
.finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId);
});
this.pending.set(attemptId, done);
@@ -409,122 +349,12 @@ export class PortfolioService {
return reserved;
}
async get(userId: string, accountId: string): Promise<PortfolioDto> {
const result = await this.read(userId, accountId);
if (!result.data) {
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
}
return result.data;
}
getAll(userId: string): Promise<PortfolioDto> {
return this.get(userId, 'all');
}
private async normalize(
upstream: {
averagePositionPrice?: TbankMoney;
currentPrice?: TbankMoney;
figi?: string;
instrumentType?: string;
instrumentUid?: string;
quantity?: { nano?: number | string; units?: number | string };
}[],
upstream: NonNullable<TbankPortfolio['positions']>,
money: TbankMoney[],
): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const savedMetadata = await this.metadata.savedMany(upstream);
const positions = upstream.map((position) =>
this.position(
position,
savedMetadata.get(metadataKey(position))?.data ?? null,
),
);
const cash = new Map<string, ReturnType<typeof decimal>>();
for (const value of money) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...cash].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
const normalizedPositions = positions.filter(
(position): position is PortfolioPositionDto => position !== null,
);
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, money),
positions: normalizedPositions,
cashBalances,
structure: portfolioStructure(normalizedPositions, cashBalances),
};
}
private position(
position: Parameters<PortfolioService['normalize']>[0][number],
metadata: null | TbankInstrument,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = this.money(position.currentPrice);
const averagePurchasePrice = this.money(position.averagePositionPrice);
const currentValue = currentPrice && this.value(quantity, currentPrice);
const absoluteReturn =
currentPrice &&
averagePurchasePrice &&
currentPrice.currency === averagePurchasePrice.currency ?
{
currency: currentPrice.currency,
value: formatDecimal(
multiply(
subtract(
decimalFromString(currentPrice.value),
decimalFromString(averagePurchasePrice.value),
),
quantity,
),
),
} :
null;
return {
figi,
instrumentUid,
displayName:
metadata?.name?.trim() ||
metadata?.ticker?.trim() ||
figi ||
'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
private value(
quantity: ReturnType<typeof decimal>,
price: MoneyDto,
): MoneyDto {
return {
currency: price.currency,
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
};
}
private money(value: TbankMoney | undefined): MoneyDto | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value ?
{ currency, value: formatDecimal(decimal(value)) } :
null;
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
}
}
@@ -43,9 +43,9 @@ export function portfolioStructure(
currencies.set(money.currency, group);
};
for (const position of positions) {
const type = ['BOND', 'STOCK'].includes(position.type) ?
position.type :
'OTHER';
const type = ['BOND', 'STOCK'].includes(position.type)
? position.type
: 'OTHER';
if (position.currentValue) include(type, position.currentValue);
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
}
@@ -65,8 +65,8 @@ export function portfolioStructure(
value: formatDecimal(value),
// Rounded to six decimal places using integer arithmetic. Only the
// resulting dimensionless percentage is passed to chart geometry.
sharePercent: canCalculateShares ?
formatDecimal({
sharePercent: canCalculateShares
? formatDecimal({
value:
(value.value *
DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
@@ -74,8 +74,8 @@ export function portfolioStructure(
total.value / ROUNDING_DIVISOR) /
total.value,
scale: SHARE_PERCENT_DECIMAL_PLACES,
}) :
null,
})
: null,
}));
return {
currency,
@@ -44,23 +44,17 @@ export class ForecastRepository {
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
}
const connection = await tx.tbankConnection.findFirst({
where: {
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
});
if (!connection) return null;
} else if (!(await tx.tbankConnection.findFirst({
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
}))) return null;
const key = { ...owner, scopeKey: input.scopeKey };
const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key },
});
const fallback =
previous?.membershipRevision === input.membershipRevision ?
previous :
null;
previous?.membershipRevision === input.membershipRevision
? previous
: null;
if (!input.candidate) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({
where: {
@@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client';
import type { PortfolioDto } from '../presentation/portfolio.dto';
import {
tbankSourceNeedsRefresh,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
const EXPECTED_UPDATED_ACCOUNTS = 1;
@@ -54,7 +52,7 @@ export class PortfolioRepository {
revision: string,
accountId: string,
processRunId: string,
active: ReadonlySet<string>,
pending: ReadonlyMap<string, Promise<void>>,
force: boolean,
) {
return this.prisma.$transaction(async (tx) => {
@@ -85,7 +83,7 @@ export class PortfolioRepository {
if (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
pending.has(previous.attemptId)
) {
return { work: previous, launch: false };
}
@@ -152,7 +150,7 @@ export class PortfolioRepository {
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
});
return result.count === EXPECTED_UPDATED_ACCOUNTS;
}
@@ -1,16 +1,110 @@
import type { Money } from '../../../../common/money.types';
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
import {
add,
decimalFromString,
formatDecimal,
isZero,
multiply,
subtract,
} from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
metadataKey,
type TbankInstrument,
type TbankMoney,
TbankPortfolio,
TbankPositions,
} from '../../../../integrations/tbank/tbank.api';
import { portfolioStructure } from '../domain/portfolio-structure';
const types: Record<string, string> = {
share: 'STOCK',
bond: 'BOND',
etf: 'FUND',
currency: 'CURRENCY',
futures: 'FUTURE',
option: 'OPTION',
sp: 'STRUCTURED_NOTE',
dfa: 'DIGITAL_FINANCIAL_ASSET',
};
function mapPortfolio(
upstream: NonNullable<TbankPortfolio['positions']>,
cash: TbankMoney[],
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
lastSuccessfulAt: string,
): PortfolioDto {
const positions = upstream
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
.filter((position): position is PortfolioPositionDto => position !== null);
const balances = new Map<string, ReturnType<typeof decimal>>();
for (const value of cash) {
const currency = value.currency?.trim().toUpperCase();
const amount = decimal(value);
if (!currency || isZero(amount)) continue;
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
}
const cashBalances = [...balances].map(([currency, value]) => ({
currency,
value: formatDecimal(value),
}));
return {
lastSuccessfulAt,
isStale: false,
totalsByCurrency: totalsByCurrency(upstream, cash),
positions,
cashBalances,
structure: portfolioStructure(positions, cashBalances),
};
}
function mapPosition(
position: NonNullable<TbankPortfolio['positions']>[number],
metadata: null | TbankInstrument,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
const figi = position.figi?.trim() || null;
const instrumentUid = position.instrumentUid?.trim() || null;
const currentPrice = money(position.currentPrice);
const averagePurchasePrice = money(position.averagePositionPrice);
const currentValue = currentPrice && {
currency: currentPrice.currency,
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
};
const absoluteReturn =
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
? {
currency: currentPrice.currency,
value: formatDecimal(multiply(
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
quantity,
)),
}
: null;
return {
figi,
instrumentUid,
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
quantity: formatDecimal(quantity),
currentValue,
averagePurchasePrice,
absoluteReturn,
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
};
}
function money(value: TbankMoney | undefined): Money | null {
const currency = value?.currency?.trim().toUpperCase();
return currency && value
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
function totalsByCurrency(
positions: NonNullable<TbankPortfolio['positions']>,
@@ -44,4 +138,4 @@ function totalsByCurrency(
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
}
export { totalsByCurrency };
export { mapPortfolio, totalsByCurrency };
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { ForecastService } from './application/forecast.service';
import { PortfolioService } from './application/portfolio.service';
import { ForecastRepository } from './infrastructure/forecast.repository';
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
import { PortfolioController } from './presentation/portfolio.controller';
@Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
imports: [AuthModule, TbankModule],
controllers: [PortfolioController],
providers: [
PortfolioService,
@@ -1,5 +1,6 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { HttpErrorCode } from '../../../common/error-code';
import {
supportedTbankAccounts,
TbankClient,
@@ -29,7 +30,7 @@ export class TbankConnectionService {
async put(userId: string, value: string) {
const token = value.trim();
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError });
const accounts = await this.tokens.translateInvalidToken(() =>
this.tbank.validateToken(token),
);

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