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

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