Compare commits
30
Commits
9cc9fbdcff
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03e75f8d2b | ||
|
|
dbb1748a5f | ||
|
|
01e2de6a04 | ||
|
|
30e96e51e2 | ||
|
|
a7619d5c8f | ||
|
|
d698ed9425 | ||
|
|
51f345bdc5 | ||
|
|
0f27da9fd1 | ||
|
|
1be6db65d7 | ||
|
|
99de4af678 | ||
|
|
14a204de0d | ||
|
|
a13cf84ead | ||
|
|
7fe88a12c7 | ||
|
|
190eb90ffe | ||
|
|
fb2e851e7e | ||
|
|
15a3d5f331 | ||
|
|
bee34290fb | ||
|
|
6cd9c2dd28 | ||
|
|
b24433092b | ||
|
|
33592c1d5f | ||
|
|
183b7f1efd | ||
|
|
b78c215ff1 | ||
|
|
a970eaaf7a | ||
|
|
e76eb545d7 | ||
|
|
7d2262fb43 | ||
|
|
33a5135741 | ||
|
|
53b2794963 | ||
|
|
2d14081d27 | ||
|
|
0303e60b64 | ||
|
|
92f216d520 |
+17
-8
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
|
|||||||
|
|
||||||
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
||||||
для публичных consumers.
|
для публичных consumers.
|
||||||
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
|
||||||
request context и ограничивает поля событий. Jest unit tests, изолированные
|
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
|
||||||
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||||
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||||
|
|
||||||
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
|
|||||||
|
|
||||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||||
модулями providers, функции и типы. Внешний production import идёт через этот
|
модулями providers, функции и типы. Внешний production import идёт через этот
|
||||||
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и
|
API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
|
||||||
|
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
|
||||||
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
||||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
публичные входы для protobuf conversion, protocol/error vocabulary и interval
|
||||||
не загружает DI/Prisma.
|
rules: импорт этих значений и расчётов не загружает DI/Prisma.
|
||||||
Внутри модуля используй прямые локальные импорты.
|
Внутри модуля используй прямые локальные импорты.
|
||||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||||
Новый export требует текущего потребителя; controller и persistence остаются
|
Новый export требует текущего потребителя; controller и persistence остаются
|
||||||
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
|||||||
Исключения для индексов, default values, enum/type declarations и readonly
|
Исключения для индексов, default values, enum/type declarations и readonly
|
||||||
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
||||||
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
Machine codes и protocol values задаются строковыми enum у владельца.
|
||||||
|
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
|
||||||
|
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
|
||||||
|
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
|
||||||
|
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
|
||||||
|
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
@@ -261,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
|
|||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
controllers делегируют application, use cases не импортируют Prisma. Чистые
|
||||||
|
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
|
||||||
|
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
|
||||||
|
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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-границе.
|
||||||
|
|
||||||
|
|||||||
@@ -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-и-сохранность-данных).
|
||||||
|
|||||||
@@ -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"
|
|
||||||
@@ -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
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -63,9 +63,12 @@ npm run verify
|
|||||||
Он запускает zero-warning lint,
|
Он запускает zero-warning lint,
|
||||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
|
||||||
выполните обе команды.
|
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
|
||||||
|
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
|
||||||
|
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
|
||||||
|
и не является функциональным merge gate.
|
||||||
|
|
||||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||||
@@ -74,17 +77,38 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
|||||||
|
|
||||||
### CI и обновления зависимостей
|
### CI и обновления зависимостей
|
||||||
|
|
||||||
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||||
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
|
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||||
|
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||||
|
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
||||||
|
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||||
|
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||||
|
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
||||||
|
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
|
||||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||||
|
|
||||||
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||||
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
значениями, блокирует любой runtime advisory через
|
||||||
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
|
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
|
||||||
|
через `npm audit --include=dev --audit-level=high`. Moderate advisory
|
||||||
|
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
|
||||||
|
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
|
||||||
|
`.gitleaksignore` перечисляет только проверенные исторические
|
||||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||||
Новые коммиты этими исключениями не покрываются.
|
Новые коммиты этими исключениями не покрываются.
|
||||||
|
|
||||||
|
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
||||||
|
workflow в статус. При внедрении администратор должен заменить required checks
|
||||||
|
для защищённых веток на `Backend verify / commits (pull_request)`,
|
||||||
|
`Backend verify / security (pull_request)` и
|
||||||
|
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
|
||||||
|
если они требовались. Все три PR-проверки должны оставаться обязательными.
|
||||||
|
Публикация образов по тегам остаётся в `image.yml`.
|
||||||
|
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
|
||||||
|
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
|
||||||
|
runners. Конфигурация runner управляется вне этого репозитория.
|
||||||
|
|
||||||
Renovate группирует minor/patch updates, а major требует согласования в
|
Renovate группирует minor/patch updates, а major требует согласования в
|
||||||
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
||||||
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
||||||
@@ -133,6 +157,10 @@ lint. Он не защищает от усложнения уже учтённо
|
|||||||
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
||||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||||
|
|
||||||
|
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||||
|
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||||
|
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||||
|
|
||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/common/logging/request-logging.middleware.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -54,7 +49,7 @@
|
|||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/candle.service.ts": {
|
"src/modules/investments/instruments/application/candle.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 4
|
"count": 3
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 4
|
"count": 4
|
||||||
@@ -169,7 +164,7 @@
|
|||||||
},
|
},
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 3
|
"count": 1
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 7
|
"count": 7
|
||||||
@@ -203,4 +198,4 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+109
-1
@@ -15,6 +15,9 @@ import globals from 'globals';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||||
|
const prismaImports = [
|
||||||
|
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||||
@@ -48,7 +51,11 @@ export default defineConfig(
|
|||||||
'perfectionist/sort-classes': 'off',
|
'perfectionist/sort-classes': 'off',
|
||||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||||
'@stylistic/arrow-parens': ['error', 'always'],
|
'@stylistic/arrow-parens': ['error', 'always'],
|
||||||
'@stylistic/operator-linebreak': ['error', 'after'],
|
'@stylistic/operator-linebreak': [
|
||||||
|
'error',
|
||||||
|
'after',
|
||||||
|
{ overrides: { '?': 'before', ':': 'before' } },
|
||||||
|
],
|
||||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -56,6 +63,18 @@ export default defineConfig(
|
|||||||
files: ['**/*.mjs'],
|
files: ['**/*.mjs'],
|
||||||
extends: [node.configs['flat/recommended-module']],
|
extends: [node.configs['flat/recommended-module']],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
|
||||||
|
rules: {
|
||||||
|
// These scripts run after build; lint validates their source paths before dist exists.
|
||||||
|
'n/no-missing-require': ['error', {
|
||||||
|
resolverConfig: {
|
||||||
|
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
|
||||||
|
extensionAlias: { '.js': ['.ts', '.js'] },
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ['**/*.ts'],
|
files: ['**/*.ts'],
|
||||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
||||||
@@ -103,4 +122,93 @@ export default defineConfig(
|
|||||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/modules/**/domain/**/*.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: [
|
||||||
|
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
|
||||||
|
'**/*.client', '**/application/**', '**/infrastructure/**',
|
||||||
|
],
|
||||||
|
message: 'Domain uses pure rules and value types, not transport or persistence.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.controller.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [...prismaImports, '**/*.repository', '**/*.client'],
|
||||||
|
message: 'Controllers delegate to application services or use cases.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['**'],
|
||||||
|
importNamePattern: '(?:Client|Repository|PrismaService)$',
|
||||||
|
message: 'Use application orchestration rather than data providers in controllers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.use-case.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: prismaImports,
|
||||||
|
message: 'Use cases obtain data through concrete repositories.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
],
|
||||||
|
ignores: [
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'test/**/*.ts',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
|
||||||
|
message: 'Use the owning enum member for machine codes and protocol values.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
|
||||||
|
message: 'Use the owning enum member instead of a template machine code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
|
||||||
|
message: 'Use an enum member instead of an inline code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
|
||||||
|
message: 'Use an enum member instead of constructing a code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
|
||||||
|
message: 'Use a computed enum member as the protocol mapping key.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
|
||||||
|
message: 'Compare codes with their enum members, not string literals.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||||
|
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+61
-226
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.0.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.0.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -1425,29 +1425,6 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -1536,9 +1513,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.14.4",
|
"version": "1.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
|
||||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
@@ -2186,6 +2163,13 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@istanbuljs/schema": {
|
"node_modules/@istanbuljs/schema": {
|
||||||
"version": "0.1.6",
|
"version": "0.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
|
||||||
@@ -2797,29 +2781,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -5095,29 +5056,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -6270,6 +6208,16 @@
|
|||||||
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -6304,9 +6252,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-ftp": {
|
"node_modules/basic-ftp": {
|
||||||
"version": "5.3.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6415,6 +6363,19 @@
|
|||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||||
@@ -6506,6 +6467,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/builtin-modules": {
|
"node_modules/builtin-modules": {
|
||||||
@@ -7019,6 +6981,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"dev": true,
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 6.0"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
@@ -8358,29 +8321,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8587,29 +8527,6 @@
|
|||||||
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8658,29 +8575,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
@@ -9053,9 +8947,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9537,29 +9431,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9967,9 +9838,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.0",
|
"version": "10.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -11641,14 +11512,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
|
||||||
"type-is": "^1.6.18"
|
"type-is": "^1.6.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -12829,9 +12699,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
@@ -12839,6 +12709,10 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-agent": {
|
"node_modules/proxy-agent": {
|
||||||
@@ -13213,16 +13087,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/release-it/node_modules/undici": {
|
|
||||||
"version": "7.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/remeda": {
|
"node_modules/remeda": {
|
||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
@@ -13803,13 +13667,6 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sprintf-js": {
|
|
||||||
"version": "1.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
|
|
||||||
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "BSD-3-Clause"
|
|
||||||
},
|
|
||||||
"node_modules/sql-escaper": {
|
"node_modules/sql-escaper": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/sql-escaper/-/sql-escaper-1.5.1.tgz",
|
||||||
@@ -14181,29 +14038,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/glob": {
|
"node_modules/test-exclude/node_modules/glob": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
@@ -14234,9 +14068,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14633,6 +14467,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
+9
-3
@@ -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,
|
||||||
@@ -26,6 +26,7 @@
|
|||||||
"test:cov": "npm test -- --coverage",
|
"test:cov": "npm test -- --coverage",
|
||||||
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||||
|
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
|
||||||
"test:sqlite": "node test/sqlite.cjs",
|
"test:sqlite": "node test/sqlite.cjs",
|
||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
@@ -37,7 +38,7 @@
|
|||||||
"test:lint": "node --test test/eslint-config.cjs"
|
"test:lint": "node --test test/eslint-config.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -104,12 +105,17 @@
|
|||||||
"typescript-eslint": "8.70.1"
|
"typescript-eslint": "8.70.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"proxy-addr": "2.0.8",
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.3.0"
|
"multer": "2.4.0"
|
||||||
},
|
},
|
||||||
"@prisma/config@7.10.0": {
|
"@prisma/config@7.10.0": {
|
||||||
"deepmerge-ts": "8.0.2"
|
"deepmerge-ts": "8.0.2"
|
||||||
},
|
},
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.1",
|
||||||
|
"undici": "7.29.1"
|
||||||
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"mysql2": "3.24.4"
|
"mysql2": "3.24.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
|
|||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
||||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
|
|
||||||
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
||||||
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
||||||
|
|||||||
+2
-36
@@ -1,10 +1,8 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||||
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
}),
|
}),
|
||||||
LoggerModule.forRootAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
|
||||||
pinoHttp: {
|
|
||||||
level: config.getOrThrow('LOG_LEVEL'),
|
|
||||||
messageKey: 'message',
|
|
||||||
autoLogging: false,
|
|
||||||
genReqId: (request: unknown, response) => {
|
|
||||||
const existingId = (request as { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingId === 'string' ? existingId : randomUUID();
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
return requestId;
|
|
||||||
},
|
|
||||||
redact: [
|
|
||||||
'req.headers.authorization',
|
|
||||||
'req.headers.cookie',
|
|
||||||
'req.body',
|
|
||||||
],
|
|
||||||
serializers: {
|
|
||||||
req: (request: unknown) => {
|
|
||||||
const value = request as { id?: unknown; method?: unknown };
|
|
||||||
return {
|
|
||||||
id: typeof value.id === 'string' ? value.id : undefined,
|
|
||||||
method:
|
|
||||||
typeof value.method === 'string' ? value.method : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
|
||||||
export class ProblemDetailsDto {
|
export class ProblemDetailsDto {
|
||||||
@ApiProperty({ example: 401 })
|
@ApiProperty({ example: 401 })
|
||||||
declare status: number;
|
declare status: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
@ApiProperty({ example: HttpErrorCode.Unauthorized })
|
||||||
declare code: string;
|
declare code: string;
|
||||||
|
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
||||||
|
|||||||
@@ -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',
|
||||||
|
}
|
||||||
@@ -9,24 +9,26 @@ import {
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const context = host.switchToHttp();
|
const context = host.switchToHttp();
|
||||||
const response = context.getResponse<Response>();
|
const response = context.getResponse<Response>();
|
||||||
const status =
|
const status =
|
||||||
exception instanceof HttpException ?
|
exception instanceof HttpException
|
||||||
exception.getStatus() :
|
? exception.getStatus()
|
||||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||||
|
|
||||||
const payload =
|
const payload =
|
||||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||||
const isValidation = Array.isArray(
|
const isValidation = Array.isArray(
|
||||||
(payload as { message?: unknown })?.message,
|
(payload as { message?: unknown })?.message,
|
||||||
);
|
);
|
||||||
const code = isValidation ?
|
const code = isValidation
|
||||||
'VALIDATION_ERROR' :
|
? HttpErrorCode.ValidationError
|
||||||
this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
response.locals ??= {};
|
response.locals ??= {};
|
||||||
response.locals.publicErrorCode = code;
|
response.locals.publicErrorCode = code;
|
||||||
response
|
response
|
||||||
@@ -35,13 +37,13 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
.json({
|
.json({
|
||||||
status,
|
status,
|
||||||
code,
|
code,
|
||||||
...(isValidation ?
|
...(isValidation
|
||||||
{
|
? {
|
||||||
errors: this.validationErrors(
|
errors: this.validationErrors(
|
||||||
(payload as { message: ValidationError[] }).message,
|
(payload as { message: ValidationError[] }).message,
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
return (
|
return (
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
||||||
{
|
{
|
||||||
400: 'BAD_REQUEST',
|
400: HttpErrorCode.BadRequest,
|
||||||
401: 'UNAUTHORIZED',
|
401: HttpErrorCode.Unauthorized,
|
||||||
404: 'NOT_FOUND',
|
404: HttpErrorCode.NotFound,
|
||||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
415: HttpErrorCode.UnsupportedMediaType,
|
||||||
500: 'INTERNAL_SERVER_ERROR',
|
500: HttpErrorCode.InternalServerError,
|
||||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
}[status] ?? HttpErrorCode.InternalServerError
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Options } from 'pino-http';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||||
|
const route: unknown = request.route;
|
||||||
|
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||||
|
typeof route.path === 'string'
|
||||||
|
? `${request.baseUrl}${route.path}`
|
||||||
|
: '<unmatched>';
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
route: path,
|
||||||
|
status: response.statusCode,
|
||||||
|
durationMs: Math.round(value.responseTime),
|
||||||
|
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||||
|
return {
|
||||||
|
level: config.getOrThrow('LOG_LEVEL'),
|
||||||
|
messageKey: 'message',
|
||||||
|
genReqId: (_request, response) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
response.setHeader('X-Request-ID', id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
customProps: (request) => {
|
||||||
|
const incoming = request.header('x-request-id');
|
||||||
|
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||||
|
},
|
||||||
|
customLogLevel: (_request, response) => {
|
||||||
|
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||||
|
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
customSuccessMessage: () => 'http.request.completed',
|
||||||
|
customErrorMessage: () => 'http.request.completed',
|
||||||
|
customSuccessObject: completion,
|
||||||
|
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||||
|
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||||
|
serializers: { req: () => undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||||
RequestContextService,
|
inject: [ConfigService],
|
||||||
RequestLoggingMiddleware,
|
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||||
StructuredLogger,
|
})],
|
||||||
],
|
providers: [StructuredLogger],
|
||||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
exports: [StructuredLogger],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
|
|
||||||
export type RequestLogContext = {
|
|
||||||
brokerageAccountId?: string;
|
|
||||||
clientRequestId?: string;
|
|
||||||
requestId: string;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextService {
|
|
||||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
|
||||||
|
|
||||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
|
||||||
return this.storage.run(context, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): RequestLogContext | undefined {
|
|
||||||
return this.storage.getStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
set(context: Partial<RequestLogContext>): void {
|
|
||||||
const current = this.storage.getStore();
|
|
||||||
if (current) Object.assign(current, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
|
||||||
|
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
|
||||||
|
|
||||||
describe('RequestLoggingMiddleware', () => {
|
|
||||||
it('returns a server ID and emits one safe completion event', () => {
|
|
||||||
const requestContext = {
|
|
||||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
const middleware = new RequestLoggingMiddleware(
|
|
||||||
requestContext as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const response = Object.assign(new EventEmitter(), {
|
|
||||||
statusCode: 200,
|
|
||||||
locals: {},
|
|
||||||
setHeader: jest.fn(),
|
|
||||||
});
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
const request: {
|
|
||||||
baseUrl: string;
|
|
||||||
header: jest.Mock<string>;
|
|
||||||
id?: string;
|
|
||||||
method: string;
|
|
||||||
route: { path: string };
|
|
||||||
} = {
|
|
||||||
method: 'GET',
|
|
||||||
baseUrl: '/api/v1',
|
|
||||||
route: { path: '/accounts/:id' },
|
|
||||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
|
||||||
};
|
|
||||||
middleware.use(request as never, response as never, next);
|
|
||||||
response.emit('finish');
|
|
||||||
|
|
||||||
expect(response.setHeader).toHaveBeenCalledWith(
|
|
||||||
'X-Request-ID',
|
|
||||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
|
||||||
);
|
|
||||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
||||||
expect(requestContext.run).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
|
||||||
}),
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'http.request.completed',
|
|
||||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { constants as httpStatus } from 'node:http2';
|
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
|
||||||
|
|
||||||
const uuid =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
|
||||||
constructor(
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly logger: StructuredLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
use(request: Request, response: Response, next: NextFunction): void {
|
|
||||||
const incoming = request.header('x-request-id');
|
|
||||||
const clientRequestId =
|
|
||||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
|
||||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
|
||||||
(request as unknown as { id: string }).id = requestId;
|
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
response.on('finish', () => {
|
|
||||||
const routeRequest: unknown = request;
|
|
||||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
|
||||||
?.path;
|
|
||||||
const route =
|
|
||||||
typeof routePath === 'string' ?
|
|
||||||
`${request.baseUrl}${routePath}` :
|
|
||||||
'<unmatched>';
|
|
||||||
const context = {
|
|
||||||
method: request.method,
|
|
||||||
route,
|
|
||||||
status: response.statusCode,
|
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
|
||||||
...(response.locals.publicErrorCode ?
|
|
||||||
{ code: response.locals.publicErrorCode as string } :
|
|
||||||
{}),
|
|
||||||
};
|
|
||||||
const level =
|
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
|
|
||||||
'error' :
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
|
|
||||||
'warn' :
|
|
||||||
'info';
|
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.requestContext.run(
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
...(clientRequestId ? { clientRequestId } : {}),
|
|
||||||
},
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
describe('StructuredLogger', () => {
|
describe('StructuredLogger', () => {
|
||||||
const context = { get: jest.fn() };
|
|
||||||
const pino = {
|
const pino = {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
@@ -12,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');
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||||
|
|
||||||
@@ -27,10 +25,7 @@ const allowedFields = new Set([
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StructuredLogger implements OnApplicationShutdown {
|
export class StructuredLogger implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(private readonly pino: PinoLogger) {}
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly pino: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
this.write('info', message, context);
|
this.write('info', message, context);
|
||||||
@@ -51,7 +46,7 @@ export class StructuredLogger implements OnApplicationShutdown {
|
|||||||
): void {
|
): void {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||||
this.pino[level](
|
this.pino[level](
|
||||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
this.safeContext(context),
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -105,9 +105,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
|||||||
JWT_SECRET: jwtSecret,
|
JWT_SECRET: jwtSecret,
|
||||||
PORT: parsedPort,
|
PORT: parsedPort,
|
||||||
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
|
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
|
||||||
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ?
|
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
|
||||||
{ TBANK_CA_CERT_PATH: tbankCaCertPath } :
|
? { TBANK_CA_CERT_PATH: tbankCaCertPath }
|
||||||
{}),
|
: {}),
|
||||||
TBANK_GRPC_ENDPOINT: tbankEndpoint,
|
TBANK_GRPC_ENDPOINT: tbankEndpoint,
|
||||||
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
|
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
|
||||||
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
|
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||||
|
|
||||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||||
const FIRST_PAGE_OFFSET = 0;
|
const FIRST_PAGE_OFFSET = 0;
|
||||||
const EXPECTED_CURSOR_ROWS = 1;
|
const EXPECTED_CURSOR_ROWS = 1;
|
||||||
@@ -152,7 +154,7 @@ export class MoexClient {
|
|||||||
pageSize === EMPTY_PAGE_SIZE ||
|
pageSize === EMPTY_PAGE_SIZE ||
|
||||||
current.length !== Math.min(pageSize, total - start)
|
current.length !== Math.min(pageSize, total - start)
|
||||||
) {
|
) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
if (index + pageSize >= total) return rows;
|
if (index + pageSize >= total) return rows;
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
@@ -172,22 +174,22 @@ export class MoexClient {
|
|||||||
}
|
}
|
||||||
return body as IssResponse;
|
return body as IssResponse;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||||
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
const columns = block.columns;
|
const columns = block.columns;
|
||||||
if (!columns.every((column) => typeof column === 'string')) {
|
if (!columns.every((column) => typeof column === 'string')) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
return block.data.map((values) => {
|
return block.data.map((values) => {
|
||||||
if (!Array.isArray(values) || values.length !== columns.length) {
|
if (!Array.isArray(values) || values.length !== columns.length) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
|
|||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||||
|
|
||||||
|
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||||
|
|
||||||
const EXPECTED_REGISTRY_TABLES = 1;
|
const EXPECTED_REGISTRY_TABLES = 1;
|
||||||
const RATING_COLUMN = 2;
|
const RATING_COLUMN = 2;
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
|
|||||||
if (this.cache.get('registry') === pending) {
|
if (this.cache.get('registry') === pending) {
|
||||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
|
||||||
});
|
});
|
||||||
this.cache.set('registry', pending);
|
this.cache.set('registry', pending);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
|
|||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
@@ -43,12 +44,12 @@ export class InstrumentMetadataRepository {
|
|||||||
|
|
||||||
async readMany(identities: MetadataIdentity[]) {
|
async readMany(identities: MetadataIdentity[]) {
|
||||||
const identifiers = identities.flatMap((identity) => [
|
const identifiers = identities.flatMap((identity) => [
|
||||||
...(identity.instrumentUid?.trim() ?
|
...(identity.instrumentUid?.trim()
|
||||||
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
|
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||||
[]),
|
: []),
|
||||||
...(identity.figi?.trim() ?
|
...(identity.figi?.trim()
|
||||||
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
|
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||||
[]),
|
: []),
|
||||||
]);
|
]);
|
||||||
if (!identifiers.length) {
|
if (!identifiers.length) {
|
||||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||||
@@ -233,9 +234,9 @@ export class InstrumentMetadataRepository {
|
|||||||
update: {
|
update: {
|
||||||
...data,
|
...data,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.metadata, payload) ?
|
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
REVISION_INCREMENT,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -256,9 +257,9 @@ export class InstrumentMetadataRepository {
|
|||||||
matchedUids,
|
matchedUids,
|
||||||
fetchedAt: now,
|
fetchedAt: now,
|
||||||
dataRevision: {
|
dataRevision: {
|
||||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
|
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
REVISION_INCREMENT,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: true },
|
connection: { invalidToken: true },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
export { metadataKey } from './instrument-metadata.repository';
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
|
|||||||
if (
|
if (
|
||||||
!identity.figi ||
|
!identity.figi ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN')
|
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||||
) {
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
|
|||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export class TbankAccessService {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: false },
|
connection: { invalidToken: false },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
@@ -205,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({
|
||||||
@@ -236,10 +237,10 @@ export class TbankAccessService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||||
}
|
}
|
||||||
if (connection.invalidToken) {
|
if (connection.invalidToken) {
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
connection.membershipAt &&
|
connection.membershipAt &&
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
|
import { TbankAccountType } from './tbank-protocol';
|
||||||
import { TbankAccount } from './tbank.client';
|
import { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
const accountTypes = {
|
const accountTypes = {
|
||||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
[TbankAccountType.TinkoffIis]: {
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
type: 'INDIVIDUAL_INVESTMENT',
|
||||||
name: 'ИИС',
|
name: 'ИИС',
|
||||||
},
|
},
|
||||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||||
ACCOUNT_TYPE_INVEST_FUND: {
|
[TbankAccountType.InvestFund]: {
|
||||||
type: 'MONEY_MARKET_FUND',
|
type: 'MONEY_MARKET_FUND',
|
||||||
name: 'Фонд денежного рынка',
|
name: 'Фонд денежного рынка',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export enum TbankErrorCode {
|
||||||
|
InvalidToken = 'INVALID_TBANK_TOKEN',
|
||||||
|
NotConnected = 'TBANK_NOT_CONNECTED',
|
||||||
|
Unavailable = 'TBANK_UNAVAILABLE',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error messages and persisted codes cross an open string boundary.
|
||||||
|
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -37,7 +38,7 @@ export class TbankMembershipService {
|
|||||||
.catch(() =>
|
.catch(() =>
|
||||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||||
userId,
|
userId,
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => this.active.delete(attemptId));
|
.finally(() => this.active.delete(attemptId));
|
||||||
@@ -62,7 +63,7 @@ export class TbankMembershipService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.access.failMembership(
|
await this.access.failMembership(
|
||||||
attempt,
|
attempt,
|
||||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { loadSync } from '@grpc/proto-loader';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
isTbankProtocolValue,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankAccountType,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankCouponType,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
TbankOperationType,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
|
it('uses the string enum values declared by the vendored protocol', () => {
|
||||||
|
const directory = join(__dirname, 'proto/contracts');
|
||||||
|
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
|
||||||
|
for (const [name, values] of Object.entries({
|
||||||
|
InstrumentIdType,
|
||||||
|
InstrumentType: TbankInstrumentType,
|
||||||
|
AccountStatus: TbankAccountStatus,
|
||||||
|
AccountType: TbankAccountType,
|
||||||
|
CandleInterval: TbankCandleInterval,
|
||||||
|
CouponType: TbankCouponType,
|
||||||
|
OperationState: TbankOperationState,
|
||||||
|
OperationType: TbankOperationType,
|
||||||
|
})) {
|
||||||
|
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
|
||||||
|
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
|
||||||
|
const descriptor = definition as { type: { value: { name: string }[] } };
|
||||||
|
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes supported wire values without accepting unknown ones', () => {
|
||||||
|
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
|
||||||
|
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
|
||||||
|
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
|
||||||
|
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// String wire values for the protocol subset used by this integration.
|
||||||
|
// proto-loader decodes enums as strings; vendor contracts remain the source.
|
||||||
|
export enum InstrumentIdType {
|
||||||
|
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
|
||||||
|
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentType {
|
||||||
|
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||||
|
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountStatus {
|
||||||
|
Open = 'ACCOUNT_STATUS_OPEN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountType {
|
||||||
|
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
|
||||||
|
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
|
||||||
|
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCandleInterval {
|
||||||
|
Day = 'CANDLE_INTERVAL_DAY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationState {
|
||||||
|
Executed = 'OPERATION_STATE_EXECUTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCouponType {
|
||||||
|
Floating = 'COUPON_TYPE_FLOATING',
|
||||||
|
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationType {
|
||||||
|
BondTax = 'OPERATION_TYPE_BOND_TAX',
|
||||||
|
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
|
||||||
|
Buy = 'OPERATION_TYPE_BUY',
|
||||||
|
BuyCard = 'OPERATION_TYPE_BUY_CARD',
|
||||||
|
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
|
||||||
|
Coupon = 'OPERATION_TYPE_COUPON',
|
||||||
|
DivExt = 'OPERATION_TYPE_DIV_EXT',
|
||||||
|
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||||
|
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
|
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
Input = 'OPERATION_TYPE_INPUT',
|
||||||
|
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
Output = 'OPERATION_TYPE_OUTPUT',
|
||||||
|
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
Sell = 'OPERATION_TYPE_SELL',
|
||||||
|
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||||
|
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
|
||||||
|
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
|
Tax = 'OPERATION_TYPE_TAX',
|
||||||
|
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
|
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
|
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider responses may contain values newer than our supported protocol subset.
|
||||||
|
export function isTbankProtocolValue(
|
||||||
|
value: unknown,
|
||||||
|
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
|
||||||
|
): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -26,8 +27,8 @@ export class TbankTokenService {
|
|||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { status } from '@grpc/grpc-js';
|
import { status } from '@grpc/grpc-js';
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TbankOperationState } from './tbank-protocol';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
describe('TbankClient operations', () => {
|
describe('TbankClient operations', () => {
|
||||||
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
|
|||||||
to: { seconds: '2' },
|
to: { seconds: '2' },
|
||||||
cursor: 'cursor',
|
cursor: 'cursor',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
state: TbankOperationState.Executed,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('passes a cursor operations request to OperationsService', async () => {
|
it('passes a cursor operations request to OperationsService', async () => {
|
||||||
@@ -175,46 +176,30 @@ describe('TbankClient metadata retries', () => {
|
|||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
])(
|
])(
|
||||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
'recovers metadata after one retry for transient gRPC status %s',
|
||||||
async (code) => {
|
async (code) => {
|
||||||
requestRpc
|
requestRpc
|
||||||
.mockRejectedValueOnce({ code })
|
|
||||||
.mockRejectedValueOnce({ code })
|
.mockRejectedValueOnce({ code })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid');
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
await jest.advanceTimersByTimeAsync(999);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||||
await jest.advanceTimersByTimeAsync(3999);
|
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
await expect(result).resolves.toEqual(instrument);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
it('rejects metadata immediately when the queue is full', async () => {
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
requestRpc.mockResolvedValue({ instrument });
|
requestRpc.mockResolvedValue({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByFigi('token', 'figi');
|
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
expect(queue.run).not.toHaveBeenCalled();
|
||||||
queue.size = 49;
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'warn',
|
|
||||||
'tbank.rpc.retry',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
|
||||||
);
|
|
||||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not bypass queue admission after an upstream failure', async () => {
|
it('does not bypass queue admission after an upstream failure', async () => {
|
||||||
@@ -222,42 +207,22 @@ describe('TbankClient metadata retries', () => {
|
|||||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
await jest.advanceTimersByTimeAsync(2000);
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||||
queue.size = 0;
|
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(4000);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
|
||||||
queue.size = 50;
|
|
||||||
await Promise.all([
|
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
|
||||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
|
||||||
expect(logger.event).toHaveBeenLastCalledWith(
|
|
||||||
'error',
|
|
||||||
'tbank.rpc.failed',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
|
||||||
);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ import type {
|
|||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
const INITIAL_REQUEST_COUNT = 0;
|
const INITIAL_REQUEST_COUNT = 0;
|
||||||
const MIN_RATE_DELAY_MS = 1;
|
const MIN_RATE_DELAY_MS = 1;
|
||||||
@@ -38,10 +46,8 @@ const NO_RPC_ATTEMPTS = 0;
|
|||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
|
|
||||||
const RATE_WINDOW_MS = 1_000;
|
const RATE_WINDOW_MS = 1_000;
|
||||||
const METADATA_MAX_ATTEMPTS = 3;
|
|
||||||
const RPC_MAX_ATTEMPTS = 2;
|
const RPC_MAX_ATTEMPTS = 2;
|
||||||
const MAX_QUEUE_SIZE = 50;
|
const MAX_QUEUE_SIZE = 50;
|
||||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
|
||||||
const RPC_RETRY_DELAY_MS = 1_000;
|
const RPC_RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
const requestDeadlineMs = 15_000;
|
const requestDeadlineMs = 15_000;
|
||||||
@@ -115,7 +121,7 @@ export type TbankOperationsByCursorRequest = {
|
|||||||
from?: TbankTimestamp;
|
from?: TbankTimestamp;
|
||||||
instrumentId?: string;
|
instrumentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
state: 'OPERATION_STATE_EXECUTED';
|
state: TbankOperationState.Executed;
|
||||||
to?: TbankTimestamp;
|
to?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorResponse = {
|
export type TbankOperationsByCursorResponse = {
|
||||||
@@ -205,10 +211,10 @@ export class TbankClient {
|
|||||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||||
).tinkoff.public.invest.api.contract.v1;
|
).tinkoff.public.invest.api.contract.v1;
|
||||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||||
const credentials = caPath ?
|
const credentials = caPath
|
||||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
||||||
ChannelCredentials.createSsl(readFileSync(caPath)) :
|
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||||
ChannelCredentials.createSsl();
|
: ChannelCredentials.createSsl();
|
||||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||||
const create = (name: string) =>
|
const create = (name: string) =>
|
||||||
new (
|
new (
|
||||||
@@ -256,7 +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 ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,14 +285,14 @@ export class TbankClient {
|
|||||||
token: string,
|
token: string,
|
||||||
figi: string,
|
figi: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstrumentByUid(
|
getInstrumentByUid(
|
||||||
token: string,
|
token: string,
|
||||||
instrumentUid: string,
|
instrumentUid: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDividends(
|
getDividends(
|
||||||
@@ -314,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 ?? []);
|
||||||
}
|
}
|
||||||
@@ -326,7 +332,7 @@ export class TbankClient {
|
|||||||
'findInstrument',
|
'findInstrument',
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
instrumentKind: TbankInstrumentType.Bond,
|
||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
@@ -337,7 +343,7 @@ export class TbankClient {
|
|||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
'bondBy',
|
'bondBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
@@ -367,7 +373,7 @@ export class TbankClient {
|
|||||||
this.instruments,
|
this.instruments,
|
||||||
'shareBy',
|
'shareBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
@@ -411,12 +417,6 @@ export class TbankClient {
|
|||||||
).then((response) => response.lastPrices ?? []);
|
).then((response) => response.lastPrices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
|
||||||
return this.getLastPrices(token, [uid]).then(
|
|
||||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||||
return this.call<{ tradingStatus?: string }>(
|
return this.call<{ tradingStatus?: string }>(
|
||||||
token,
|
token,
|
||||||
@@ -446,7 +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 ?? []);
|
||||||
@@ -454,7 +454,7 @@ export class TbankClient {
|
|||||||
|
|
||||||
private async getInstrument(
|
private async getInstrument(
|
||||||
token: string,
|
token: string,
|
||||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType,
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
try {
|
try {
|
||||||
@@ -478,13 +478,12 @@ export class TbankClient {
|
|||||||
request: object,
|
request: object,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
try {
|
try {
|
||||||
if (queueFull) {
|
if (queueFull) {
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
const response = await this.queue.run(() =>
|
const response = await this.queue.run(() =>
|
||||||
this.request<Response>(token, client, method, request),
|
this.request<Response>(token, client, method, request),
|
||||||
@@ -497,44 +496,35 @@ export class TbankClient {
|
|||||||
return response;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const grpcCode = (error as ServiceError).code;
|
const grpcCode = (error as ServiceError).code;
|
||||||
const failureContext = queueFull ?
|
const failureContext = queueFull
|
||||||
{ code: 'TBANK_UNAVAILABLE' } :
|
? { code: TbankErrorCode.Unavailable }
|
||||||
{ grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull ?
|
const retryable = !queueFull && [
|
||||||
metadataCall :
|
status.DEADLINE_EXCEEDED,
|
||||||
[
|
status.RESOURCE_EXHAUSTED,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.UNAVAILABLE,
|
||||||
status.RESOURCE_EXHAUSTED,
|
].includes(grpcCode);
|
||||||
status.UNAVAILABLE,
|
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||||
].includes(grpcCode);
|
|
||||||
if (retryable && attempts < maxAttempts) {
|
|
||||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||||
operation: method,
|
operation: method,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||||
setTimeout(
|
|
||||||
resolve,
|
|
||||||
metadataCall ?
|
|
||||||
attempts * METADATA_RETRY_DELAY_MS :
|
|
||||||
RPC_RETRY_DELAY_MS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||||
throw new Error('INVALID_TBANK_TOKEN');
|
throw new Error(TbankErrorCode.InvalidToken);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
@@ -542,10 +532,10 @@ export class TbankClient {
|
|||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<Response>(
|
private request<Response>(
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
|
||||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||||
import { Environment } from './config/env.validation';
|
import { Environment } from './config/env.validation';
|
||||||
import { shouldExposeSwagger } from './config/swagger.config';
|
import { shouldExposeSwagger } from './config/swagger.config';
|
||||||
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
|
|||||||
});
|
});
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidUnknownValues: true,
|
forbidUnknownValues: true,
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum AuthErrorCode {
|
||||||
|
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
|
}
|
||||||
@@ -8,8 +8,11 @@ import * as bcrypt from 'bcrypt';
|
|||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
import { Session, User } from '../../../../generated/prisma/client';
|
import { Session, User } from '../../../../generated/prisma/client';
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
@@ -32,12 +35,12 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
@@ -51,7 +54,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUniqueConstraintError(error)) {
|
if (isUniqueConstraintError(error)) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -61,7 +64,7 @@ export class AuthService {
|
|||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -70,24 +73,24 @@ export class AuthService {
|
|||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
);
|
);
|
||||||
if (!user || !valid) {
|
if (!user || !valid) {
|
||||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
let payload: RefreshPayload;
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
@@ -102,10 +105,10 @@ export class AuthService {
|
|||||||
if (session && !session.revokedAt) {
|
if (session && !session.revokedAt) {
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
if (session.refreshTokenHash !== this.hash(token)) {
|
if (session.refreshTokenHash !== this.hash(token)) {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
return this.rotateSession(session, session.refreshTokenHash);
|
return this.rotateSession(session, session.refreshTokenHash);
|
||||||
}
|
}
|
||||||
@@ -142,13 +145,13 @@ export class AuthService {
|
|||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
async userById(id: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -163,7 +166,7 @@ export class AuthService {
|
|||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
expectedRefreshTokenHash?: string,
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: session.userId,
|
sub: session.userId,
|
||||||
@@ -183,7 +186,7 @@ export class AuthService {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||||
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
|
|||||||
typeof error === 'object' &&
|
typeof error === 'object' &&
|
||||||
error !== null &&
|
error !== null &&
|
||||||
'code' in error &&
|
'code' in error &&
|
||||||
(error as { code?: unknown }).code === 'P2002'
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface AuthUser {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthenticationResult = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sid: string;
|
sid: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { Environment } from '../../../config/env.validation';
|
import { Environment } from '../../../config/env.validation';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
|
|
||||||
validate(payload: AccessPayload): AccessPayload {
|
validate(payload: AccessPayload): AccessPayload {
|
||||||
if (payload.type !== 'access') {
|
if (payload.type !== 'access') {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { CredentialsDto } from './auth.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
@@ -84,14 +85,9 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
const token = this.refreshToken(request);
|
||||||
const token = this.refreshToken(request);
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@@ -116,11 +112,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private respond(
|
private respond(
|
||||||
response: Response,
|
response: Response,
|
||||||
result: {
|
result: AuthenticationResult,
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: { email: string; id: string };
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
return { user: result.user, accessToken: result.accessToken };
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private readonly requestContext: RequestContextService) {
|
constructor(private readonly logger: PinoLogger) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<{ user?: AccessPayload }>();
|
.getRequest<{ user?: AccessPayload }>();
|
||||||
if (request.user?.sub) {
|
if (request.user?.sub) {
|
||||||
this.requestContext.set({ userId: request.user.sub });
|
this.logger.assign({ userId: request.user.sub });
|
||||||
}
|
}
|
||||||
return Boolean(allowed);
|
return Boolean(allowed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,24 +22,23 @@ import {
|
|||||||
const NEXT_MONTH_OFFSET = 1;
|
const NEXT_MONTH_OFFSET = 1;
|
||||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||||
|
|
||||||
export const BankErrorCode = {
|
export enum BankErrorCode {
|
||||||
InvalidInput: 'BANK_INVALID_INPUT',
|
CardArchived = 'BANK_CARD_ARCHIVED',
|
||||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
|
||||||
NotFound: 'BANK_NOT_FOUND',
|
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
|
||||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
|
||||||
InvalidDate: 'BANK_INVALID_DATE',
|
IntentConflict = 'BANK_INTENT_CONFLICT',
|
||||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
InvalidDate = 'BANK_INVALID_DATE',
|
||||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
InvalidInput = 'BANK_INVALID_INPUT',
|
||||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
InvalidMoney = 'BANK_INVALID_MONEY',
|
||||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
InvalidRefund = 'BANK_INVALID_REFUND',
|
||||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
NotFound = 'BANK_NOT_FOUND',
|
||||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
|
||||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
OperationDeleted = 'BANK_OPERATION_DELETED',
|
||||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
RefundLimit = 'BANK_REFUND_LIMIT',
|
||||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
VersionConflict = 'BANK_VERSION_CONFLICT',
|
||||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
WriteConflict = 'BANK_WRITE_CONFLICT',
|
||||||
} as const;
|
}
|
||||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
|
||||||
|
|
||||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||||
throw new BadRequestException({ code });
|
throw new BadRequestException({ code });
|
||||||
|
|||||||
@@ -103,17 +103,13 @@ export class BankCardsService {
|
|||||||
id,
|
id,
|
||||||
name: dto.name ?? previous.name,
|
name: dto.name ?? previous.name,
|
||||||
bankName:
|
bankName:
|
||||||
dto.bankName === undefined ?
|
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||||
(previous.bankName ?? undefined) :
|
|
||||||
(dto.bankName ?? undefined),
|
|
||||||
lastFour:
|
lastFour:
|
||||||
dto.lastFour === undefined ?
|
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||||
(previous.lastFour ?? undefined) :
|
|
||||||
(dto.lastFour ?? undefined),
|
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
dto.paymentSystem === undefined ?
|
dto.paymentSystem === undefined
|
||||||
previous.paymentSystem :
|
? previous.paymentSystem
|
||||||
dto.paymentSystem,
|
: dto.paymentSystem,
|
||||||
openingBalance:
|
openingBalance:
|
||||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||||
openingDate: dto.openingDate ?? previous.openingDate,
|
openingDate: dto.openingDate ?? previous.openingDate,
|
||||||
@@ -143,12 +139,12 @@ export class BankCardsService {
|
|||||||
...input,
|
...input,
|
||||||
paymentSystem: input.paymentSystem ?? null,
|
paymentSystem: input.paymentSystem ?? null,
|
||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
...(dto.archived === undefined ?
|
...(dto.archived === undefined
|
||||||
{} :
|
? {}
|
||||||
{
|
: {
|
||||||
archivedAt: dto.archived ?
|
archivedAt: dto.archived
|
||||||
(previous.archivedAt ?? new Date()) :
|
? (previous.archivedAt ?? new Date())
|
||||||
null,
|
: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,29 +43,29 @@ export class BankOperationsService {
|
|||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
): Promise<BankOperationDto> {
|
): Promise<BankOperationDto> {
|
||||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||||
const target = row.targetCardId ?
|
const target = row.targetCardId
|
||||||
await this.repository.card(tx, row.userId, row.targetCardId) :
|
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||||
null;
|
: null;
|
||||||
const source = row.originalExpenseId ?
|
const source = row.originalExpenseId
|
||||||
await this.repository.operation(tx, row.userId, row.originalExpenseId) :
|
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||||
null;
|
: null;
|
||||||
const categoryId = source?.categoryId ?? row.categoryId;
|
const categoryId = source?.categoryId ?? row.categoryId;
|
||||||
const category = categoryId ?
|
const category = categoryId
|
||||||
await tx.expenseCategory.findFirst({
|
? await tx.expenseCategory.findFirst({
|
||||||
where: { id: categoryId, userId: row.userId },
|
where: { id: categoryId, userId: row.userId },
|
||||||
}) :
|
})
|
||||||
null;
|
: null;
|
||||||
const refunds =
|
const refunds =
|
||||||
row.kind === BankOperationKind.Expense || source ?
|
row.kind === BankOperationKind.Expense || source
|
||||||
await tx.bankCardOperation.findMany({
|
? await tx.bankCardOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId: row.userId,
|
userId: row.userId,
|
||||||
originalExpenseId: source?.id ?? row.id,
|
originalExpenseId: source?.id ?? row.id,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
...(source ? { id: { not: row.id } } : {}),
|
...(source ? { id: { not: row.id } } : {}),
|
||||||
},
|
},
|
||||||
}) :
|
})
|
||||||
[];
|
: [];
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind as BankOperationDto['kind'],
|
kind: row.kind as BankOperationDto['kind'],
|
||||||
@@ -78,12 +78,12 @@ export class BankOperationsService {
|
|||||||
originalExpenseId: row.originalExpenseId,
|
originalExpenseId: row.originalExpenseId,
|
||||||
amount: rubMoney(row.amountKopecks),
|
amount: rubMoney(row.amountKopecks),
|
||||||
availableRefund:
|
availableRefund:
|
||||||
row.kind === BankOperationKind.Expense || source ?
|
row.kind === BankOperationKind.Expense || source
|
||||||
rubMoney(
|
? rubMoney(
|
||||||
(source?.amountKopecks ?? row.amountKopecks) -
|
(source?.amountKopecks ?? row.amountKopecks) -
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
date: row.date,
|
date: row.date,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
version: row.version,
|
version: row.version,
|
||||||
@@ -336,9 +336,9 @@ export class BankOperationsService {
|
|||||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||||
expenses = false,
|
expenses = false,
|
||||||
) {
|
) {
|
||||||
const period = expenses ?
|
const period = expenses
|
||||||
undefined :
|
? undefined
|
||||||
periodBounds(query as BankOperationsQueryDto);
|
: periodBounds(query as BankOperationsQueryDto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await this.repository.card(tx, userId, id);
|
await this.repository.card(tx, userId, id);
|
||||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||||
@@ -347,9 +347,9 @@ export class BankOperationsService {
|
|||||||
userId,
|
userId,
|
||||||
deletedAt: null,
|
deletedAt: null,
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
...(period ?
|
...(period
|
||||||
{ date: { gte: period.from, lte: period.to } } :
|
? { date: { gte: period.from, lte: period.to } }
|
||||||
{ kind: BankOperationKind.Expense }),
|
: { kind: BankOperationKind.Expense }),
|
||||||
};
|
};
|
||||||
const total = await tx.bankCardOperation.count({ where });
|
const total = await tx.bankCardOperation.count({ where });
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
|||||||
@@ -81,13 +81,13 @@ export function bankSummary(
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const expense =
|
const expense =
|
||||||
operation.kind === BankOperationKind.Expense ?
|
operation.kind === BankOperationKind.Expense
|
||||||
operation.amountKopecks :
|
? operation.amountKopecks
|
||||||
ZERO_KOPECKS;
|
: ZERO_KOPECKS;
|
||||||
const refund =
|
const refund =
|
||||||
operation.kind === BankOperationKind.Refund ?
|
operation.kind === BankOperationKind.Refund
|
||||||
operation.amountKopecks :
|
? operation.amountKopecks
|
||||||
ZERO_KOPECKS;
|
: ZERO_KOPECKS;
|
||||||
expenses += expense;
|
expenses += expense;
|
||||||
refunds += refund;
|
refunds += refund;
|
||||||
if (operation.categoryId !== null) {
|
if (operation.categoryId !== null) {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Prisma } from '../../../../../generated/prisma/client';
|
import { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
@@ -40,19 +41,19 @@ export class BankCardsRepository {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const code =
|
const code =
|
||||||
error instanceof Prisma.PrismaClientKnownRequestError ?
|
error instanceof Prisma.PrismaClientKnownRequestError
|
||||||
error.code :
|
? error.code
|
||||||
'';
|
: '';
|
||||||
const busy =
|
const busy =
|
||||||
code === 'P2034' ||
|
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
|
||||||
code === 'P2028' ||
|
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
|
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
|
||||||
@@ -12,15 +12,15 @@ export class BrokerageAccountsService {
|
|||||||
async list(userId: string, observe = false) {
|
async list(userId: string, observe = false) {
|
||||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||||
const data: BrokerageAccountDto[] | null =
|
const data: BrokerageAccountDto[] | null =
|
||||||
saved.membership.availability === 'missing' ?
|
saved.membership.availability === 'missing'
|
||||||
null :
|
? null
|
||||||
saved.accounts.map((account) => ({
|
: saved.accounts.map((account) => ({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
name: account.name,
|
name: account.name,
|
||||||
type: account.type as BrokerageAccountDto['type'],
|
type: account.type as BrokerageAccountDto['type'],
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
portfolioSummary: account.snapshot ?
|
portfolioSummary: account.snapshot
|
||||||
{
|
? {
|
||||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||||
stockPositionCount: account.snapshot.positions.filter(
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'STOCK',
|
(position) => position.type === 'STOCK',
|
||||||
@@ -28,8 +28,8 @@ export class BrokerageAccountsService {
|
|||||||
bondPositionCount: account.snapshot.positions.filter(
|
bondPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'BOND',
|
(position) => position.type === 'BOND',
|
||||||
).length,
|
).length,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
@@ -47,18 +47,18 @@ export class BrokerageAccountsService {
|
|||||||
)
|
)
|
||||||
.digest('base64url'),
|
.digest('base64url'),
|
||||||
availability:
|
availability:
|
||||||
data === null ?
|
data === null
|
||||||
'missing' :
|
? 'missing'
|
||||||
saved.accounts.some((account) => !account.snapshot) ?
|
: saved.accounts.some((account) => !account.snapshot)
|
||||||
'partial' :
|
? 'partial'
|
||||||
'complete',
|
: 'complete',
|
||||||
fetchedAt: saved.membership.lastSuccessfulAt ?
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
new Date(saved.membership.lastSuccessfulAt) :
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: 300_000,
|
freshForMs: 300_000,
|
||||||
work: saved.membership.sync.id ?
|
work: saved.membership.sync.id
|
||||||
{ ...saved.membership.sync, id: saved.membership.sync.id } :
|
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||||
null,
|
: null,
|
||||||
dependencies: saved.accounts.map((account) => account.meta),
|
dependencies: saved.accounts.map((account) => account.meta),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -189,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
).toEqual(['10.25', '-0.25']);
|
).toEqual(['10.25', '-0.25']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
|
||||||
|
seed([
|
||||||
|
{
|
||||||
|
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T21:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-15T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-14',
|
||||||
|
'2026-01-16',
|
||||||
|
);
|
||||||
|
expect(response.data?.buckets).toEqual([
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
coupon: { currency: 'RUB', value: '0.2' },
|
||||||
|
dividend: { currency: 'RUB', value: '-0.1' },
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||||
|
totalsByCurrency: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
|
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
@@ -96,9 +97,9 @@ export class IncomeAnalyticsService {
|
|||||||
{ ...current.meta, role: 'selected' },
|
{ ...current.meta, role: 'selected' },
|
||||||
{ ...prior.meta, role: 'previous' },
|
{ ...prior.meta, role: 'previous' },
|
||||||
{
|
{
|
||||||
...(plan.yearAgo && year ?
|
...(plan.yearAgo && year
|
||||||
year.meta :
|
? year.meta
|
||||||
investmentResourceMeta({
|
: investmentResourceMeta({
|
||||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||||
dataRevision: 'unavailable',
|
dataRevision: 'unavailable',
|
||||||
availability: 'missing',
|
availability: 'missing',
|
||||||
@@ -119,9 +120,9 @@ export class IncomeAnalyticsService {
|
|||||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
dataRevision: current.meta.dataRevision,
|
dataRevision: current.meta.dataRevision,
|
||||||
availability: current.meta.availability,
|
availability: current.meta.availability,
|
||||||
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 : STALE_FRESHNESS_MS,
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
coverage: current.meta.coverage,
|
coverage: current.meta.coverage,
|
||||||
@@ -144,13 +145,13 @@ export class IncomeAnalyticsService {
|
|||||||
current.meta.lastSuccessfulAt,
|
current.meta.lastSuccessfulAt,
|
||||||
),
|
),
|
||||||
previous:
|
previous:
|
||||||
prior.items === null ?
|
prior.items === null
|
||||||
null :
|
? null
|
||||||
comparison(selected, plan.previous, qualifying(prior.items)),
|
: comparison(selected, plan.previous, qualifying(prior.items)),
|
||||||
yearComparison:
|
yearComparison:
|
||||||
plan.yearAgo && year?.items !== null && year ?
|
plan.yearAgo && year?.items !== null && year
|
||||||
comparison(selected, plan.yearAgo, qualifying(year.items)) :
|
? comparison(selected, plan.yearAgo, qualifying(year.items))
|
||||||
null,
|
: null,
|
||||||
bucketGranularity: plan.granularity,
|
bucketGranularity: plan.granularity,
|
||||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||||
instruments: instruments(selected),
|
instruments: instruments(selected),
|
||||||
@@ -165,7 +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,7 +185,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
|||||||
if (!entry.payment) continue;
|
if (!entry.payment) continue;
|
||||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||||
const value = decimalFromString(entry.payment.value);
|
const value = decimalFromString(entry.payment.value);
|
||||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||||
total.coupon = add(total.coupon, value);
|
total.coupon = add(total.coupon, value);
|
||||||
} else total.dividend = add(total.dividend, value);
|
} else total.dividend = add(total.dividend, value);
|
||||||
values.set(entry.payment.currency, total);
|
values.set(entry.payment.currency, total);
|
||||||
@@ -231,13 +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),
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
utcDate,
|
utcDate,
|
||||||
} from '../investment-calendar';
|
} from '../investment-calendar';
|
||||||
|
|
||||||
|
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||||
|
|
||||||
const MONDAY_WEEKDAY_INDEX = 1;
|
const MONDAY_WEEKDAY_INDEX = 1;
|
||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
const INCLUSIVE_DAY_OFFSET = 1;
|
||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
@@ -39,24 +41,24 @@ export function planIncomePeriod(
|
|||||||
const selected =
|
const selected =
|
||||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||||
assertRange(selected, today);
|
assertRange(selected, today);
|
||||||
const previous = isFullMonth(selected) ?
|
const previous = isFullMonth(selected)
|
||||||
previousMonth(selected.from) :
|
? previousMonth(selected.from)
|
||||||
shiftDays(selected, -rangeDays(selected));
|
: shiftDays(selected, -rangeDays(selected));
|
||||||
const candidate = yearAgo(selected);
|
const candidate = yearAgo(selected);
|
||||||
const yearAgoRange =
|
const yearAgoRange =
|
||||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
||||||
candidate :
|
? candidate
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
previous,
|
previous,
|
||||||
yearAgo: yearAgoRange,
|
yearAgo: yearAgoRange,
|
||||||
granularity:
|
granularity:
|
||||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||||
'day' :
|
? 'day'
|
||||||
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
||||||
'week' :
|
? 'week'
|
||||||
'month',
|
: 'month',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,20 +71,20 @@ export function rangeBuckets(
|
|||||||
}
|
}
|
||||||
const buckets: DateOnlyRange[] = [];
|
const buckets: DateOnlyRange[] = [];
|
||||||
let cursor =
|
let cursor =
|
||||||
granularity === 'week' ?
|
granularity === 'week'
|
||||||
addDays(
|
? addDays(
|
||||||
range.from,
|
range.from,
|
||||||
-(
|
-(
|
||||||
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
||||||
DAYS_PER_WEEK
|
DAYS_PER_WEEK
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
: `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
||||||
while (cursor <= range.to) {
|
while (cursor <= range.to) {
|
||||||
const end =
|
const end =
|
||||||
granularity === 'week' ?
|
granularity === 'week'
|
||||||
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
|
? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
|
||||||
endOfMonth(cursor);
|
: endOfMonth(cursor);
|
||||||
buckets.push({
|
buckets.push({
|
||||||
from: cursor < range.from ? range.from : cursor,
|
from: cursor < range.from ? range.from : cursor,
|
||||||
to: end > range.to ? range.to : end,
|
to: end > range.to ? range.to : end,
|
||||||
@@ -153,9 +155,9 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
|||||||
const shift = (value: string) => {
|
const shift = (value: string) => {
|
||||||
const [year, month, day] = dateParts(value);
|
const [year, month, day] = dateParts(value);
|
||||||
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
||||||
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
|
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
|
||||||
result :
|
? result
|
||||||
null;
|
: null;
|
||||||
};
|
};
|
||||||
const from = shift(range.from);
|
const from = shift(range.from);
|
||||||
const to = shift(range.to);
|
const to = shift(range.to);
|
||||||
@@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function invalidPeriod(): BadRequestException {
|
function invalidPeriod(): BadRequestException {
|
||||||
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
|
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
|
||||||
@@ -5,10 +5,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -16,19 +15,18 @@ import {
|
|||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
candleGaps,
|
candleGaps,
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
candleWindows,
|
candleWindows,
|
||||||
utcDay,
|
utcDay,
|
||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
|
||||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
|
||||||
const REPLACED_WINDOW_COUNT = 1;
|
const REPLACED_WINDOW_COUNT = 1;
|
||||||
const NEXT_WINDOW_OFFSET = 1;
|
const NEXT_WINDOW_OFFSET = 1;
|
||||||
|
|
||||||
@@ -54,7 +52,6 @@ type Producer = {
|
|||||||
};
|
};
|
||||||
const months = { month: 1, halfYear: 6, year: 12 };
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
const tailFreshForMs = 60_000;
|
const tailFreshForMs = 60_000;
|
||||||
const prefetchReadBatchSize = 100;
|
|
||||||
function scope(input: CandleInput) {
|
function scope(input: CandleInput) {
|
||||||
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -70,9 +67,9 @@ function rangeFor(
|
|||||||
if (!history) {
|
if (!history) {
|
||||||
throw new ServiceUnavailableException({
|
throw new ServiceUnavailableException({
|
||||||
code:
|
code:
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
'STOCK_HISTORY_START_UNAVAILABLE' :
|
? InstrumentErrorCode.StockHistoryStartUnavailable
|
||||||
'BOND_HISTORY_START_UNAVAILABLE',
|
: InstrumentErrorCode.BondHistoryStartUnavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
from.setTime(utcDay(new Date(history)).valueOf());
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
@@ -92,7 +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,
|
||||||
@@ -114,9 +110,9 @@ export class CandleService {
|
|||||||
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)) {
|
||||||
@@ -139,14 +135,14 @@ export class CandleService {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
!saved || !covered ?
|
!saved || !covered
|
||||||
null :
|
? null
|
||||||
{
|
: {
|
||||||
candles: saved.data,
|
candles: saved.data,
|
||||||
currency:
|
currency:
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
detail.instrument?.currency?.toUpperCase() || null :
|
? detail.instrument?.currency?.toUpperCase() || null
|
||||||
null,
|
: null,
|
||||||
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||||
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||||
},
|
},
|
||||||
@@ -159,8 +155,8 @@ export class CandleService {
|
|||||||
work,
|
work,
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: range ? [publicRange(range)] : [],
|
requested: range ? [publicRange(range)] : [],
|
||||||
completeRanges: range ?
|
completeRanges: range
|
||||||
completeCoverage.flatMap((row) => {
|
? completeCoverage.flatMap((row) => {
|
||||||
const from = new Date(
|
const from = new Date(
|
||||||
Math.max(row.from.valueOf(), range.from.valueOf()),
|
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||||
);
|
);
|
||||||
@@ -168,22 +164,22 @@ export class CandleService {
|
|||||||
Math.min(row.to.valueOf(), range.to.valueOf()),
|
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||||
);
|
);
|
||||||
return from < to ? [publicRange({ from, to })] : [];
|
return from < to ? [publicRange({ from, to })] : [];
|
||||||
}) :
|
})
|
||||||
[],
|
: [],
|
||||||
missingRanges: missing.map(publicRange),
|
missingRanges: missing.map(publicRange),
|
||||||
asOf: fetchedAt?.toISOString() ?? null,
|
asOf: fetchedAt?.toISOString() ?? null,
|
||||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
|
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||||
'known' :
|
? 'known'
|
||||||
'unknown',
|
: 'unknown',
|
||||||
historyState: complete ?
|
historyState: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
work?.state === 'failed' ?
|
: work?.state === 'failed'
|
||||||
'failed' :
|
? 'failed'
|
||||||
work?.state === 'running' ?
|
: work?.state === 'running'
|
||||||
'loading' :
|
? 'loading'
|
||||||
covered ?
|
: covered
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
},
|
},
|
||||||
dependencies: [
|
dependencies: [
|
||||||
detail.meta,
|
detail.meta,
|
||||||
@@ -193,9 +189,9 @@ export class CandleService {
|
|||||||
dataRevision: String(row.dataRevision),
|
dataRevision: String(row.dataRevision),
|
||||||
availability: 'complete',
|
availability: 'complete',
|
||||||
fetchedAt: row.fetchedAt,
|
fetchedAt: row.fetchedAt,
|
||||||
freshForMs: row.isComplete ?
|
freshForMs: row.isComplete
|
||||||
Number.POSITIVE_INFINITY :
|
? Number.POSITIVE_INFINITY
|
||||||
tailFreshForMs,
|
: tailFreshForMs,
|
||||||
work: null,
|
work: null,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -204,110 +200,6 @@ export class CandleService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
prefetch(
|
|
||||||
userId: string,
|
|
||||||
revision: string,
|
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
|
||||||
) {
|
|
||||||
const key = JSON.stringify([
|
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
positions
|
|
||||||
.map((position) => [position.instrumentUid, position.type])
|
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
.sort(),
|
|
||||||
]);
|
|
||||||
const existing = this.prefetches.get(key);
|
|
||||||
if (existing) return existing;
|
|
||||||
const work = this.prefetchPositions(userId, revision, positions).finally(
|
|
||||||
() => this.prefetches.delete(key),
|
|
||||||
);
|
|
||||||
this.prefetches.set(key, work);
|
|
||||||
return work;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async prefetchPositions(
|
|
||||||
userId: string,
|
|
||||||
revision: string,
|
|
||||||
positions: { instrumentUid?: null | string; type: string }[],
|
|
||||||
) {
|
|
||||||
const now = new Date();
|
|
||||||
const unique = new Map(
|
|
||||||
positions
|
|
||||||
.filter(
|
|
||||||
(position) =>
|
|
||||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
|
||||||
)
|
|
||||||
.map((position) => [position.instrumentUid ?? '', position]),
|
|
||||||
);
|
|
||||||
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
|
||||||
[];
|
|
||||||
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
|
||||||
for (const instrumentType of ['share', 'bond'] as const) {
|
|
||||||
const uids = [...unique]
|
|
||||||
.filter(
|
|
||||||
([, position]) =>
|
|
||||||
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
|
||||||
)
|
|
||||||
.map(([uid]) => uid);
|
|
||||||
for (
|
|
||||||
let offset = FIRST_BATCH_OFFSET;
|
|
||||||
offset < uids.length;
|
|
||||||
offset += prefetchReadBatchSize
|
|
||||||
) {
|
|
||||||
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
|
||||||
details.push(
|
|
||||||
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
|
||||||
'detail',
|
|
||||||
])),
|
|
||||||
);
|
|
||||||
coverage.push(...(await this.repository.coverage(batch)));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
|
||||||
for (const [uid, position] of unique) {
|
|
||||||
const instrumentType: CandleInput['instrumentType'] =
|
|
||||||
position.type === 'STOCK' ? 'share' : 'bond';
|
|
||||||
const detail = byUid.get(uid);
|
|
||||||
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
|
||||||
const plan = planInvestmentScreen(
|
|
||||||
{ screen: 'account' },
|
|
||||||
{
|
|
||||||
membershipKnown: true,
|
|
||||||
accounts: [],
|
|
||||||
instruments: [
|
|
||||||
{
|
|
||||||
uid,
|
|
||||||
type: position.type as 'BOND' | 'STOCK',
|
|
||||||
historyFrom: history ? new Date(history) : null,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!detail?.instrument ||
|
|
||||||
plan.sources.some((source) => source.kind === 'candles')
|
|
||||||
) {
|
|
||||||
const input = { uid, instrumentType, period: 'all' as const };
|
|
||||||
const range =
|
|
||||||
detail?.instrument && history ?
|
|
||||||
rangeFor(input, detail.instrument, now) :
|
|
||||||
null;
|
|
||||||
if (
|
|
||||||
!range ||
|
|
||||||
candleGaps(
|
|
||||||
range,
|
|
||||||
coverage.filter((row) => row.instrumentUid === uid),
|
|
||||||
now,
|
|
||||||
).length
|
|
||||||
) {
|
|
||||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
request(
|
request(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
@@ -411,17 +303,7 @@ export class CandleService {
|
|||||||
window.from,
|
window.from,
|
||||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
);
|
);
|
||||||
const rows = candles.flatMap((candle) => {
|
const rows = candles.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) throw new Error('Invalid candle response');
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -448,7 +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) {
|
||||||
@@ -457,7 +339,7 @@ export class CandleService {
|
|||||||
}
|
}
|
||||||
success = result;
|
success = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
}
|
}
|
||||||
if (error instanceof ServiceUnavailableException) {
|
if (error instanceof ServiceUnavailableException) {
|
||||||
@@ -508,22 +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,
|
|
||||||
BACKGROUND_SYNC_PRIORITY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'candles.persistence-failed', {
|
this.logger.event('error', 'candles.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -14,6 +15,7 @@ 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,
|
||||||
@@ -79,14 +81,14 @@ export class CatalogDiscoveryService {
|
|||||||
|
|
||||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||||
const discovery = await this.read(userId, input, observe);
|
const discovery = await this.read(userId, input, observe);
|
||||||
const sources = discovery.data ?
|
const sources = discovery.data
|
||||||
await this.enrichment.readMany(
|
? await this.enrichment.readMany(
|
||||||
userId,
|
userId,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
discovery.data.map((item) => item.uid),
|
discovery.data.map((item) => item.uid),
|
||||||
observe,
|
observe,
|
||||||
) :
|
)
|
||||||
[];
|
: [];
|
||||||
const dependencies = [
|
const dependencies = [
|
||||||
discovery.meta,
|
discovery.meta,
|
||||||
...sources.map((source) => source.meta),
|
...sources.map((source) => source.meta),
|
||||||
@@ -114,22 +116,22 @@ export class CatalogDiscoveryService {
|
|||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability:
|
availability:
|
||||||
discovery.data === null ?
|
discovery.data === null
|
||||||
'missing' :
|
? 'missing'
|
||||||
sources.every((source) => source.meta.availability === 'complete') ?
|
: sources.every((source) => source.meta.availability === 'complete')
|
||||||
'complete' :
|
? 'complete'
|
||||||
'partial',
|
: 'partial',
|
||||||
fetchedAt: discovery.meta.lastSuccessfulAt ?
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||||
new Date(discovery.meta.lastSuccessfulAt) :
|
? new Date(discovery.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.catalog,
|
freshForMs: tbankSourceTtlMs.catalog,
|
||||||
work: discovery.meta.sync.id ?
|
work: discovery.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: discovery.meta.sync.id,
|
id: discovery.meta.sync.id,
|
||||||
state: discovery.meta.sync.state,
|
state: discovery.meta.sync.state,
|
||||||
errorCode: discovery.meta.sync.errorCode,
|
errorCode: discovery.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -177,17 +179,17 @@ export class CatalogDiscoveryService {
|
|||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: source.meta.availability,
|
availability: source.meta.availability,
|
||||||
fetchedAt: source.meta.lastSuccessfulAt ?
|
fetchedAt: source.meta.lastSuccessfulAt
|
||||||
new Date(source.meta.lastSuccessfulAt) :
|
? new Date(source.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
work: source.meta.sync.id ?
|
work: source.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: source.meta.sync.id,
|
id: source.meta.sync.id,
|
||||||
state: source.meta.sync.state,
|
state: source.meta.sync.state,
|
||||||
errorCode: source.meta.sync.errorCode,
|
errorCode: source.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -208,36 +210,36 @@ export class CatalogDiscoveryService {
|
|||||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code:
|
code:
|
||||||
instrumentType === 'share' ?
|
instrumentType === 'share'
|
||||||
'STOCK_TICKER_AMBIGUOUS' :
|
? InstrumentErrorCode.StockTickerAmbiguous
|
||||||
'BOND_TICKER_AMBIGUOUS',
|
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = lookup.data ?
|
const result = lookup.data
|
||||||
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
|
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||||
null;
|
: null;
|
||||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||||
return {
|
return {
|
||||||
data: result?.data ?
|
data: result?.data
|
||||||
{ ...result.data, slug: ticker.trim().toUpperCase() } :
|
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||||
null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||||
dataRevision: dependencies
|
dataRevision: dependencies
|
||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: result?.meta.availability ?? 'missing',
|
availability: result?.meta.availability ?? 'missing',
|
||||||
fetchedAt: lookup.meta.lastSuccessfulAt ?
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||||
new Date(lookup.meta.lastSuccessfulAt) :
|
? new Date(lookup.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
work: lookup.meta.sync.id ?
|
work: lookup.meta.sync.id
|
||||||
{
|
? {
|
||||||
id: lookup.meta.sync.id,
|
id: lookup.meta.sync.id,
|
||||||
state: lookup.meta.sync.state,
|
state: lookup.meta.sync.state,
|
||||||
errorCode: lookup.meta.sync.errorCode,
|
errorCode: lookup.meta.sync.errorCode,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -334,19 +336,19 @@ export class CatalogDiscoveryService {
|
|||||||
const token = await this.access.tokenForAttempt(attempt);
|
const token = await this.access.tokenForAttempt(attempt);
|
||||||
if (token) {
|
if (token) {
|
||||||
const found =
|
const found =
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
await this.tbank.findShares(token, input.query) :
|
? await this.tbank.findShares(token, input.query)
|
||||||
await this.tbank.findBonds(token, input.query);
|
: await this.tbank.findBonds(token, input.query);
|
||||||
success = await this.repository.publish(
|
success = await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
input,
|
input,
|
||||||
selectCatalogInstruments(
|
selectCatalogInstruments(
|
||||||
input.exactTicker ?
|
input.exactTicker
|
||||||
found.filter(
|
? found.filter(
|
||||||
(item) =>
|
(item) =>
|
||||||
item.ticker?.trim().toUpperCase() === input.query,
|
item.ticker?.trim().toUpperCase() === input.query,
|
||||||
) :
|
)
|
||||||
found,
|
: found,
|
||||||
input.query,
|
input.query,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
).slice(
|
).slice(
|
||||||
@@ -357,11 +359,11 @@ export class CatalogDiscoveryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,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;
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,7 @@ import {
|
|||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
@@ -36,6 +35,7 @@ import {
|
|||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
|
||||||
@@ -124,9 +124,9 @@ export class CatalogEnrichmentService {
|
|||||||
range,
|
range,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const work = inputs.length ?
|
const work = inputs.length
|
||||||
await this.repository.workMany(userId, revision, inputs) :
|
? await this.repository.workMany(userId, revision, inputs)
|
||||||
[];
|
: [];
|
||||||
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||||
return inputs.map((input) => {
|
return inputs.map((input) => {
|
||||||
const row = rows.find(
|
const row = rows.find(
|
||||||
@@ -136,16 +136,16 @@ export class CatalogEnrichmentService {
|
|||||||
const payload = row?.payload as CatalogPayload | undefined;
|
const payload = row?.payload as CatalogPayload | undefined;
|
||||||
return {
|
return {
|
||||||
input,
|
input,
|
||||||
version: row ?
|
version: row
|
||||||
{
|
? {
|
||||||
instrumentUid: row.instrumentUid,
|
instrumentUid: row.instrumentUid,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
kind: row.kind,
|
kind: row.kind,
|
||||||
rangeKey: row.rangeKey,
|
rangeKey: row.rangeKey,
|
||||||
dataRevision: row.dataRevision,
|
dataRevision: row.dataRevision,
|
||||||
fetchedAt: row.fetchedAt,
|
fetchedAt: row.fetchedAt,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
payload,
|
payload,
|
||||||
instrument: payload?.instrument ?? null,
|
instrument: payload?.instrument ?? null,
|
||||||
price: payload?.price ?? null,
|
price: payload?.price ?? null,
|
||||||
@@ -177,9 +177,9 @@ export class CatalogEnrichmentService {
|
|||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
const rows = inputs.length ?
|
const rows = inputs.length
|
||||||
await this.repository.readSources(inputs) :
|
? await this.repository.readSources(inputs)
|
||||||
[];
|
: [];
|
||||||
const ids: string[] = [];
|
const ids: string[] = [];
|
||||||
for (const input of inputs) {
|
for (const input of inputs) {
|
||||||
const row = rows.find(
|
const row = rows.find(
|
||||||
@@ -242,18 +242,18 @@ export class CatalogEnrichmentService {
|
|||||||
observe = false,
|
observe = false,
|
||||||
) {
|
) {
|
||||||
const kinds: CatalogSource['kind'][] =
|
const kinds: CatalogSource['kind'][] =
|
||||||
section === 'company' ?
|
section === 'company'
|
||||||
['detail', 'description', 'fundamentals'] :
|
? ['detail', 'description', 'fundamentals']
|
||||||
[
|
: [
|
||||||
...(instrumentType === 'bond' ?
|
...(instrumentType === 'bond'
|
||||||
['bond-current' as const] :
|
? ['bond-current' as const]
|
||||||
['detail' as const]),
|
: ['detail' as const]),
|
||||||
'last-price',
|
'last-price',
|
||||||
'trading-status',
|
'trading-status',
|
||||||
'comparison',
|
'comparison',
|
||||||
...(section === 'metrics' ?
|
...(section === 'metrics'
|
||||||
['coupons' as const, 'yield' as const, 'rating' as const] :
|
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||||
[]),
|
: []),
|
||||||
];
|
];
|
||||||
const sources = await this.readMany(
|
const sources = await this.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -279,18 +279,18 @@ export class CatalogEnrichmentService {
|
|||||||
dataRevision: dependencies
|
dataRevision: dependencies
|
||||||
.map((dependency) => dependency.dataRevision)
|
.map((dependency) => dependency.dataRevision)
|
||||||
.join('/'),
|
.join('/'),
|
||||||
availability: !first.instrument ?
|
availability: !first.instrument
|
||||||
'missing' :
|
? 'missing'
|
||||||
complete ?
|
: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
'partial',
|
: 'partial',
|
||||||
fetchedAt: first.meta.lastSuccessfulAt ?
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
new Date(first.meta.lastSuccessfulAt) :
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
null,
|
: null,
|
||||||
freshForMs:
|
freshForMs:
|
||||||
section === 'company' ?
|
section === 'company'
|
||||||
tbankSourceTtlMs.company :
|
? tbankSourceTtlMs.company
|
||||||
tbankSourceTtlMs.quote,
|
: tbankSourceTtlMs.quote,
|
||||||
work: null,
|
work: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
});
|
||||||
@@ -315,9 +315,9 @@ export class CatalogEnrichmentService {
|
|||||||
): Promise<CatalogPayload> {
|
): Promise<CatalogPayload> {
|
||||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||||
const instrument =
|
const instrument =
|
||||||
input.instrumentType === 'share' ?
|
input.instrumentType === 'share'
|
||||||
await this.tbank.getShare(token, input.uid) :
|
? await this.tbank.getShare(token, input.uid)
|
||||||
await this.tbank.getBond(token, input.uid);
|
: await this.tbank.getBond(token, input.uid);
|
||||||
if (instrument && instrument.uid !== input.uid) {
|
if (instrument && instrument.uid !== input.uid) {
|
||||||
throw new Error('Catalog UID mismatch');
|
throw new Error('Catalog UID mismatch');
|
||||||
}
|
}
|
||||||
@@ -352,17 +352,7 @@ export class CatalogEnrichmentService {
|
|||||||
gap.from,
|
gap.from,
|
||||||
gap.to,
|
gap.to,
|
||||||
);
|
);
|
||||||
const rows = response.map((candle) => {
|
const rows = response.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) {
|
|
||||||
throw new Error('Invalid candle response');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||||
throw new Error('Cancelled candle comparison');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
}
|
||||||
@@ -400,9 +390,9 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
const details = await this.repository.readSources([
|
const details = await this.repository.readSources([
|
||||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||||
...(input.instrumentType === 'bond' ?
|
...(input.instrumentType === 'bond'
|
||||||
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
|
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||||
[]),
|
: []),
|
||||||
]);
|
]);
|
||||||
const instrument = details
|
const instrument = details
|
||||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||||
@@ -415,36 +405,36 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
if (input.kind === 'description') {
|
if (input.kind === 'description') {
|
||||||
return {
|
return {
|
||||||
description: instrument?.assetUid ?
|
description: instrument?.assetUid
|
||||||
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
|
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (input.kind === 'fundamentals') {
|
if (input.kind === 'fundamentals') {
|
||||||
return {
|
return {
|
||||||
fundamentals: instrument?.assetUid ?
|
fundamentals: instrument?.assetUid
|
||||||
await this.tbank.getFundamentals(token, instrument?.assetUid) :
|
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||||
const year = Number(input.rangeKey);
|
const year = Number(input.rangeKey);
|
||||||
const [from, to] = input.rangeKey?.startsWith('[') ?
|
const [from, to] = input.rangeKey?.startsWith('[')
|
||||||
(JSON.parse(input.rangeKey) as [string, string]) :
|
? (JSON.parse(input.rangeKey) as [string, string])
|
||||||
[
|
: [
|
||||||
`${year - payoutHistoryYears}-01-01`,
|
`${year - payoutHistoryYears}-01-01`,
|
||||||
`${year + payoutFutureYears}-01-01`,
|
`${year + payoutFutureYears}-01-01`,
|
||||||
];
|
];
|
||||||
return input.kind === 'coupons' ?
|
return input.kind === 'coupons'
|
||||||
{
|
? {
|
||||||
coupons: await this.tbank.getBondCoupons(
|
coupons: await this.tbank.getBondCoupons(
|
||||||
token,
|
token,
|
||||||
input.uid,
|
input.uid,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
{
|
: {
|
||||||
dividends: await this.tbank.getDividends(
|
dividends: await this.tbank.getDividends(
|
||||||
token,
|
token,
|
||||||
input.uid,
|
input.uid,
|
||||||
@@ -461,20 +451,20 @@ export class CatalogEnrichmentService {
|
|||||||
if (input.kind === 'yield') {
|
if (input.kind === 'yield') {
|
||||||
return {
|
return {
|
||||||
effectiveYield:
|
effectiveYield:
|
||||||
instrument?.ticker && instrument.isin && instrument.classCode ?
|
instrument?.ticker && instrument.isin && instrument.classCode
|
||||||
await this.moex.bondYield(
|
? await this.moex.bondYield(
|
||||||
instrument.ticker,
|
instrument.ticker,
|
||||||
instrument.isin,
|
instrument.isin,
|
||||||
instrument.classCode,
|
instrument.classCode,
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (input.kind === 'rating') {
|
if (input.kind === 'rating') {
|
||||||
return {
|
return {
|
||||||
creditRating: instrument?.isin ?
|
creditRating: instrument?.isin
|
||||||
await this.ratings.rating(instrument.isin) :
|
? await this.ratings.rating(instrument.isin)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
throw new Error('Unsupported catalog source');
|
throw new Error('Unsupported catalog source');
|
||||||
@@ -520,11 +510,11 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
failed = true;
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -533,17 +523,17 @@ export class CatalogEnrichmentService {
|
|||||||
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||||
null;
|
null;
|
||||||
const value =
|
const value =
|
||||||
item.input.kind === 'last-price' ?
|
item.input.kind === 'last-price'
|
||||||
{
|
? {
|
||||||
price: price ?
|
price: price
|
||||||
{
|
? {
|
||||||
instrumentUid: item.input.uid,
|
instrumentUid: item.input.uid,
|
||||||
price: price.price,
|
price: price.price,
|
||||||
time: price.time,
|
time: price.time,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
} :
|
}
|
||||||
payload;
|
: payload;
|
||||||
// Keep accepting observers while publication awaits SQLite.
|
// Keep accepting observers while publication awaits SQLite.
|
||||||
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||||
for (const observer of item.observers) {
|
for (const observer of item.observers) {
|
||||||
@@ -572,7 +562,7 @@ export class CatalogEnrichmentService {
|
|||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
this.queue.length = 0;
|
this.queue.length = 0;
|
||||||
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
@@ -596,17 +586,17 @@ function sourceInputs(
|
|||||||
uid,
|
uid,
|
||||||
kind,
|
kind,
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ?
|
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||||
{ ticker: range?.tickers?.[uid] || range?.ticker } :
|
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||||
{}),
|
: {}),
|
||||||
...(['coupons', 'dividends'].includes(kind) ?
|
...(['coupons', 'dividends'].includes(kind)
|
||||||
{
|
? {
|
||||||
rangeKey: range ?
|
rangeKey: range
|
||||||
JSON.stringify([range.from, range.to]) :
|
? JSON.stringify([range.from, range.to])
|
||||||
String(new Date().getUTCFullYear()),
|
: String(new Date().getUTCFullYear()),
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -679,9 +669,9 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
return input.instrumentType === 'bond' &&
|
return input.instrumentType === 'bond' &&
|
||||||
(input.kind === 'detail' || input.kind === 'bond-current') ?
|
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||||
`bond-metadata:${input.uid}` :
|
? `bond-metadata:${input.uid}`
|
||||||
catalogScope(input);
|
: catalogScope(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotationScale = 9;
|
const quotationScale = 9;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
|
|
||||||
export const catalogSearchLimit = 30;
|
export const catalogSearchLimit = 30;
|
||||||
|
|
||||||
export function selectCatalogInstruments<T extends TbankShare>(
|
export function selectCatalogInstruments<T extends TbankShare>(
|
||||||
@@ -9,9 +11,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
|||||||
): (T & { uid: string })[] {
|
): (T & { uid: string })[] {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const kind =
|
const kind =
|
||||||
instrumentType === 'share' ?
|
instrumentType === 'share'
|
||||||
'INSTRUMENT_TYPE_SHARE' :
|
? TbankInstrumentType.Share
|
||||||
'INSTRUMENT_TYPE_BOND';
|
: TbankInstrumentType.Bond;
|
||||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||||
return found
|
return found
|
||||||
.filter(
|
.filter(
|
||||||
@@ -29,9 +31,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
|||||||
a.uid.localeCompare(b.uid),
|
a.uid.localeCompare(b.uid),
|
||||||
)
|
)
|
||||||
.filter((instrument) => {
|
.filter((instrument) => {
|
||||||
const key = instrument.isin?.trim() ?
|
const key = instrument.isin?.trim()
|
||||||
`isin:${instrument.isin.trim().toUpperCase()}` :
|
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||||
`uid:${instrument.uid}`;
|
: `uid:${instrument.uid}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
|||||||
const MOSCOW_OFFSET_MS = 10_800_000;
|
const MOSCOW_OFFSET_MS = 10_800_000;
|
||||||
export function timestamp(value?: TbankTimestamp): null | string {
|
export function timestamp(value?: TbankTimestamp): null | string {
|
||||||
const seconds = Number(value?.seconds);
|
const seconds = Number(value?.seconds);
|
||||||
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
|
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
|
||||||
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
|
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
export function moscowDay(iso: string): string {
|
export function moscowDay(iso: string): string {
|
||||||
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
||||||
|
|||||||
@@ -43,8 +43,8 @@ export function instrumentPosition(
|
|||||||
currentValue,
|
currentValue,
|
||||||
absoluteReturn,
|
absoluteReturn,
|
||||||
averagePurchasePrice:
|
averagePurchasePrice:
|
||||||
positive && totalCost ?
|
positive && totalCost
|
||||||
{
|
? {
|
||||||
currency: totalCost.currency,
|
currency: totalCost.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
@@ -53,15 +53,15 @@ export function instrumentPosition(
|
|||||||
PRICE_DECIMAL_PLACES,
|
PRICE_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
||||||
accounts: positions.map((p) => ({
|
accounts: positions.map((p) => ({
|
||||||
...p,
|
...p,
|
||||||
returnPercent:
|
returnPercent:
|
||||||
decimalFromString(p.quantity).value > ZERO_QUANTITY ?
|
decimalFromString(p.quantity).value > ZERO_QUANTITY
|
||||||
percent(p.absoluteReturn, cost(p)) :
|
? percent(p.absoluteReturn, cost(p))
|
||||||
null,
|
: null,
|
||||||
})),
|
})),
|
||||||
isStale: portfolio.isStale,
|
isStale: portfolio.isStale,
|
||||||
observedAt: portfolio.lastSuccessfulAt,
|
observedAt: portfolio.lastSuccessfulAt,
|
||||||
@@ -69,8 +69,8 @@ export function instrumentPosition(
|
|||||||
}
|
}
|
||||||
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||||
const price = position.averagePurchasePrice;
|
const price = position.averagePurchasePrice;
|
||||||
return price ?
|
return price
|
||||||
{
|
? {
|
||||||
currency: price.currency,
|
currency: price.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(
|
multiply(
|
||||||
@@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
|||||||
decimalFromString(position.quantity),
|
decimalFromString(position.quantity),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||||
const currency = values[0]?.currency;
|
const currency = values[0]?.currency;
|
||||||
|
|||||||
@@ -37,18 +37,18 @@ export class BondCatalogService {
|
|||||||
data:
|
data:
|
||||||
result.data?.map((item) => {
|
result.data?.map((item) => {
|
||||||
const short = toBond(item);
|
const short = toBond(item);
|
||||||
const detail = item.detail ?
|
const detail = item.detail
|
||||||
toBond({ ...item.detail, uid: item.uid }) :
|
? toBond({ ...item.detail, uid: item.uid })
|
||||||
short;
|
: short;
|
||||||
const last = item.price;
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
classCode: detail.classCode ?? short.classCode,
|
classCode: detail.classCode ?? short.classCode,
|
||||||
isin: detail.isin ?? short.isin,
|
isin: detail.isin ?? short.isin,
|
||||||
apiTradeAvailable: short.apiTradeAvailable,
|
apiTradeAvailable: short.apiTradeAvailable,
|
||||||
pricePercent: last?.price ?
|
pricePercent: last?.price
|
||||||
formatDecimal(decimal(last.price)) :
|
? formatDecimal(decimal(last.price))
|
||||||
null,
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
}) ?? null,
|
}) ?? null,
|
||||||
@@ -63,9 +63,9 @@ export class BondCatalogService {
|
|||||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...toBond(result.data), slug: result.data.slug } :
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +82,9 @@ export class BondCatalogService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...toBond(result.data), slug: result.data.slug } :
|
? { ...toBond(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||||
|
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -62,9 +64,9 @@ export class BondIncomeService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
bondMetrics(result.values, result.unavailable, result.observedAt) :
|
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -99,18 +101,18 @@ export class BondIncomeService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const events = sources[1].payload?.coupons;
|
const events = sources[1].payload?.coupons;
|
||||||
const position = portfolio.data ?
|
const position = portfolio.data
|
||||||
instrumentPosition(portfolio.data, uid, 'BOND') :
|
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||||
null;
|
: null;
|
||||||
const projected = events ?
|
const projected = events
|
||||||
bondCoupons(
|
? bondCoupons(
|
||||||
events,
|
events,
|
||||||
position,
|
position,
|
||||||
{ from, to, today },
|
{ from, to, today },
|
||||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
!portfolio.data,
|
!portfolio.data,
|
||||||
) :
|
)
|
||||||
undefined;
|
: undefined;
|
||||||
const result = await this.forecasts.result({
|
const result = await this.forecasts.result({
|
||||||
userId,
|
userId,
|
||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
@@ -121,9 +123,13 @@ export class BondIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
return result.data || !projected ?
|
if (result.data?.positionAt) {
|
||||||
result :
|
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||||
{
|
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||||
|
}
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
data: bondCoupons(
|
data: bondCoupons(
|
||||||
events ?? [],
|
events ?? [],
|
||||||
null,
|
null,
|
||||||
@@ -146,19 +152,19 @@ export function bondCoupons(
|
|||||||
const { from, to, today } = window;
|
const { from, to, today } = window;
|
||||||
const quantity =
|
const quantity =
|
||||||
position?.accounts.length &&
|
position?.accounts.length &&
|
||||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
|
||||||
decimalFromString(position.quantity) :
|
? decimalFromString(position.quantity)
|
||||||
null;
|
: null;
|
||||||
const coupons = events
|
const coupons = events
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const perBond = money(event.payOneBond);
|
const perBond = money(event.payOneBond);
|
||||||
const timing = !paymentDate ?
|
const timing = !paymentDate
|
||||||
('undated' as const) :
|
? ('undated' as const)
|
||||||
paymentDate >= today ?
|
: paymentDate >= today
|
||||||
('upcoming' as const) :
|
? ('upcoming' as const)
|
||||||
('past' as const);
|
: ('past' as const);
|
||||||
return {
|
return {
|
||||||
number: event.couponNumber ?? null,
|
number: event.couponNumber ?? null,
|
||||||
paymentDate,
|
paymentDate,
|
||||||
@@ -172,14 +178,14 @@ export function bondCoupons(
|
|||||||
perBond &&
|
perBond &&
|
||||||
timing === 'upcoming' &&
|
timing === 'upcoming' &&
|
||||||
recordDate &&
|
recordDate &&
|
||||||
recordDate >= today ?
|
recordDate >= today
|
||||||
{
|
? {
|
||||||
currency: perBond.currency,
|
currency: perBond.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perBond.value)),
|
multiply(quantity, decimalFromString(perBond.value)),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||||
@@ -225,8 +231,8 @@ export function bondMetrics(
|
|||||||
clean &&
|
clean &&
|
||||||
coupon.currency === clean.currency &&
|
coupon.currency === clean.currency &&
|
||||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||||
decimalFromString(clean.value).value > ZERO_AMOUNT ?
|
decimalFromString(clean.value).value > ZERO_AMOUNT
|
||||||
formatDecimal(
|
? formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(
|
multiply(
|
||||||
decimalFromString(coupon.value),
|
decimalFromString(coupon.value),
|
||||||
@@ -238,14 +244,14 @@ export function bondMetrics(
|
|||||||
),
|
),
|
||||||
COUPON_YIELD_DECIMAL_PLACES,
|
COUPON_YIELD_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
null;
|
: null;
|
||||||
const couponType =
|
const couponType =
|
||||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
|
next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
|
||||||
next.couponType :
|
? next.couponType
|
||||||
instrument.floatingCouponFlag === true ?
|
: instrument.floatingCouponFlag === true
|
||||||
'COUPON_TYPE_FLOATING' :
|
? TbankCouponType.Floating
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
creditRating,
|
creditRating,
|
||||||
coupon,
|
coupon,
|
||||||
|
|||||||
@@ -48,9 +48,9 @@ export class BondMarketService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
bondQuote(result.values, result.unavailable, result.observedAt) :
|
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -83,8 +83,8 @@ export function bondQuote(
|
|||||||
const cleanPrice =
|
const cleanPrice =
|
||||||
pricePercent !== null &&
|
pricePercent !== null &&
|
||||||
nominal &&
|
nominal &&
|
||||||
decimalFromString(nominal.value).value > ZERO_AMOUNT ?
|
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
||||||
{
|
? {
|
||||||
currency: nominal.currency,
|
currency: nominal.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
@@ -96,11 +96,11 @@ export function bondQuote(
|
|||||||
PRICE_DECIMAL_PLACES,
|
PRICE_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
const dirtyPrice =
|
const dirtyPrice =
|
||||||
cleanPrice && aci && aci.currency === cleanPrice.currency ?
|
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||||
{
|
? {
|
||||||
currency: cleanPrice.currency,
|
currency: cleanPrice.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
add(
|
add(
|
||||||
@@ -108,8 +108,8 @@ export function bondQuote(
|
|||||||
decimalFromString(aci.value),
|
decimalFromString(aci.value),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
pricePercent,
|
pricePercent,
|
||||||
cleanPrice,
|
cleanPrice,
|
||||||
@@ -118,9 +118,9 @@ export function bondQuote(
|
|||||||
priceAt,
|
priceAt,
|
||||||
tradingStatus,
|
tradingStatus,
|
||||||
changePercentagePoints:
|
changePercentagePoints:
|
||||||
last?.price && previous?.close ?
|
last?.price && previous?.close
|
||||||
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
|
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||||
null,
|
: null,
|
||||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt,
|
observedAt,
|
||||||
|
|||||||
@@ -123,9 +123,9 @@ describe('bond financial projections', () => {
|
|||||||
{
|
{
|
||||||
couponDate: time('2026-10-10'),
|
couponDate: time('2026-10-10'),
|
||||||
payOneBond:
|
payOneBond:
|
||||||
scenario === 'unknown' ?
|
scenario === 'unknown'
|
||||||
undefined :
|
? undefined
|
||||||
money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
|||||||
|
|
||||||
export function money(value?: TbankMoney) {
|
export function money(value?: TbankMoney) {
|
||||||
const currency = value?.currency?.toUpperCase();
|
const currency = value?.currency?.toUpperCase();
|
||||||
return value && currency ?
|
return value && currency
|
||||||
{ currency, value: formatDecimal(decimal(value)) } :
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,9 +38,9 @@ export function candleGaps(
|
|||||||
gaps = gaps.flatMap((gap) => {
|
gaps = gaps.flatMap((gap) => {
|
||||||
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
|
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
|
||||||
return [
|
return [
|
||||||
...(covered.from > gap.from ?
|
...(covered.from > gap.from
|
||||||
[{ from: gap.from, to: covered.from }] :
|
? [{ from: gap.from, to: covered.from }]
|
||||||
[]),
|
: []),
|
||||||
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum InstrumentErrorCode {
|
||||||
|
BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE',
|
||||||
|
BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS',
|
||||||
|
StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE',
|
||||||
|
StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS',
|
||||||
|
}
|
||||||
@@ -53,12 +53,6 @@ export class CandleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
coverage(uids: string[]) {
|
|
||||||
return this.prisma.tbankCandleCoverage.findMany({
|
|
||||||
where: { provider, instrumentUid: { in: uids }, interval },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reserve(
|
reserve(
|
||||||
owner: { revision: string; userId: string },
|
owner: { revision: string; userId: string },
|
||||||
scopeKey: string,
|
scopeKey: string,
|
||||||
@@ -100,14 +94,14 @@ export class CandleRepository {
|
|||||||
priority: nextPriority,
|
priority: nextPriority,
|
||||||
intent: {
|
intent: {
|
||||||
...previousIntent,
|
...previousIntent,
|
||||||
...(intent.force ?
|
...(intent.force
|
||||||
{
|
? {
|
||||||
force: true,
|
force: true,
|
||||||
...(intent.now && intent.now > previousNow ?
|
...(intent.now && intent.now > previousNow
|
||||||
{ now: intent.now } :
|
? { now: intent.now }
|
||||||
{}),
|
: {}),
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -263,12 +257,12 @@ export class CandleRepository {
|
|||||||
// Preserve coverage outside the requested reconciliation boundaries.
|
// Preserve coverage outside the requested reconciliation boundaries.
|
||||||
for (const previous of old) {
|
for (const previous of old) {
|
||||||
for (const part of [
|
for (const part of [
|
||||||
...(previous.from < range.from ?
|
...(previous.from < range.from
|
||||||
[{ from: previous.from, to: range.from }] :
|
? [{ from: previous.from, to: range.from }]
|
||||||
[]),
|
: []),
|
||||||
...(previous.to > range.to ?
|
...(previous.to > range.to
|
||||||
[{ from: range.to, to: previous.to }] :
|
? [{ from: range.to, to: previous.to }]
|
||||||
[]),
|
: []),
|
||||||
]) {
|
]) {
|
||||||
await tx.tbankCandleCoverage.create({
|
await tx.tbankCandleCoverage.create({
|
||||||
data: { ...previous, ...part },
|
data: { ...previous, ...part },
|
||||||
|
|||||||
+51
-42
@@ -3,12 +3,9 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type {
|
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||||
TbankBond,
|
|
||||||
TbankShare,
|
|
||||||
TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -66,8 +63,8 @@ export class CatalogDiscoveryRepository {
|
|||||||
|
|
||||||
read(input: CatalogQuery) {
|
read(input: CatalogQuery) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const alias = input.exactTicker ?
|
const alias = input.exactTicker
|
||||||
await tx.tbankInstrumentLookup.findUnique({
|
? await tx.tbankInstrumentLookup.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_identifierType_identifier: {
|
provider_identifierType_identifier: {
|
||||||
provider,
|
provider,
|
||||||
@@ -75,17 +72,17 @@ export class CatalogDiscoveryRepository {
|
|||||||
identifier: input.query,
|
identifier: input.query,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}) :
|
})
|
||||||
null;
|
: null;
|
||||||
const discovery = input.exactTicker ?
|
const discovery = input.exactTicker
|
||||||
alias ?
|
? alias
|
||||||
{
|
? {
|
||||||
instrumentUids: alias.matchedUids,
|
instrumentUids: alias.matchedUids,
|
||||||
fetchedAt: alias.fetchedAt,
|
fetchedAt: alias.fetchedAt,
|
||||||
dataRevision: alias.dataRevision,
|
dataRevision: alias.dataRevision,
|
||||||
} :
|
}
|
||||||
null :
|
: null
|
||||||
await tx.tbankCatalogDiscovery.findUnique({
|
: await tx.tbankCatalogDiscovery.findUnique({
|
||||||
where: {
|
where: {
|
||||||
provider_instrumentType_query: {
|
provider_instrumentType_query: {
|
||||||
provider,
|
provider,
|
||||||
@@ -126,17 +123,29 @@ export class CatalogDiscoveryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readSources(inputs: CatalogSource[]) {
|
readSources(inputs: CatalogSource[]) {
|
||||||
|
// Share identity predicates across UIDs instead of preparing one OR per row.
|
||||||
|
const groups = new Map<string, CatalogSource[]>();
|
||||||
|
for (const input of inputs) {
|
||||||
|
const key = JSON.stringify([
|
||||||
|
input.kind,
|
||||||
|
sourceProvider(input),
|
||||||
|
input.kind === 'detail' ? input.instrumentType : (input.rangeKey ?? ''),
|
||||||
|
]);
|
||||||
|
const group = groups.get(key);
|
||||||
|
if (group) group.push(input);
|
||||||
|
else groups.set(key, [input]);
|
||||||
|
}
|
||||||
return this.prisma.tbankInstrumentSource.findMany({
|
return this.prisma.tbankInstrumentSource.findMany({
|
||||||
where: {
|
where: {
|
||||||
provider,
|
provider,
|
||||||
OR: inputs.map((input) => ({
|
OR: [...groups.values()].map(([input, ...rest]) => ({
|
||||||
instrumentUid: input.uid,
|
instrumentUid: { in: [input, ...rest].map((entry) => entry.uid) },
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
source: sourceProvider(input),
|
source: sourceProvider(input),
|
||||||
rangeKey:
|
rangeKey:
|
||||||
input.kind === 'detail' ?
|
input.kind === 'detail'
|
||||||
input.instrumentType :
|
? input.instrumentType
|
||||||
(input.rangeKey ?? ''),
|
: (input.rangeKey ?? ''),
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -232,9 +241,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
increment: isDeepStrictEqual(
|
increment: isDeepStrictEqual(
|
||||||
previousAlias?.matchedUids,
|
previousAlias?.matchedUids,
|
||||||
matchedUids,
|
matchedUids,
|
||||||
) ?
|
)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
REVISION_INCREMENT,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -246,9 +255,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
source: sourceProvider(input),
|
source: sourceProvider(input),
|
||||||
kind: input.kind,
|
kind: input.kind,
|
||||||
rangeKey:
|
rangeKey:
|
||||||
input.kind === 'detail' ?
|
input.kind === 'detail'
|
||||||
input.instrumentType :
|
? input.instrumentType
|
||||||
(input.rangeKey ?? ''),
|
: (input.rangeKey ?? ''),
|
||||||
};
|
};
|
||||||
const previous = await tx.tbankInstrumentSource.findUnique({
|
const previous = await tx.tbankInstrumentSource.findUnique({
|
||||||
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
where: { provider_instrumentUid_source_kind_rangeKey: key },
|
||||||
@@ -359,9 +368,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
instrumentKind: instrument.instrumentKind,
|
instrumentKind: instrument.instrumentKind,
|
||||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||||
currency: instrument.currency,
|
currency: instrument.currency,
|
||||||
brand: instrument.brand ?
|
brand: instrument.brand
|
||||||
{ logoName: instrument.brand.logoName } :
|
? { logoName: instrument.brand.logoName }
|
||||||
undefined,
|
: undefined,
|
||||||
}),
|
}),
|
||||||
) as Prisma.InputJsonObject;
|
) as Prisma.InputJsonObject;
|
||||||
await tx.tbankInstrument.upsert({
|
await tx.tbankInstrument.upsert({
|
||||||
@@ -418,9 +427,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
increment: isDeepStrictEqual(
|
increment: isDeepStrictEqual(
|
||||||
previous?.matchedUids,
|
previous?.matchedUids,
|
||||||
instrumentUids,
|
instrumentUids,
|
||||||
) ?
|
)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
REVISION_INCREMENT,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -444,9 +453,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
increment: isDeepStrictEqual(
|
increment: isDeepStrictEqual(
|
||||||
previous?.instrumentUids,
|
previous?.instrumentUids,
|
||||||
instrumentUids,
|
instrumentUids,
|
||||||
) ?
|
)
|
||||||
UNCHANGED_REVISION_INCREMENT :
|
? UNCHANGED_REVISION_INCREMENT
|
||||||
REVISION_INCREMENT,
|
: REVISION_INCREMENT,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -462,7 +471,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: true },
|
connection: { invalidToken: true },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
await tx.tbankSyncWork.updateMany({
|
await tx.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -472,7 +481,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -480,9 +489,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sourceProvider(input: CatalogSource) {
|
function sourceProvider(input: CatalogSource) {
|
||||||
return input.kind === 'yield' || input.kind === 'bond-events' ?
|
return input.kind === 'yield' || input.kind === 'bond-events'
|
||||||
'moex' :
|
? 'moex'
|
||||||
input.kind === 'rating' ?
|
: input.kind === 'rating'
|
||||||
'ncr' :
|
? 'ncr'
|
||||||
'tbank';
|
: 'tbank';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { toSavedCandle } from './tbank-candle.mapper';
|
||||||
|
|
||||||
|
describe('T-Bank candle mapping', () => {
|
||||||
|
it('keeps signed nano precision and requires explicit completeness', () => {
|
||||||
|
const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } };
|
||||||
|
expect(toSavedCandle(input)).toEqual({
|
||||||
|
time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false,
|
||||||
|
});
|
||||||
|
expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true);
|
||||||
|
expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } });
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ close: { units: '1' } },
|
||||||
|
{ time: { seconds: 'invalid' }, close: { units: '1' } },
|
||||||
|
{ time: { seconds: '1767225600' } },
|
||||||
|
])('rejects missing or malformed source fields: %j', (input) => {
|
||||||
|
expect(() => toSavedCandle(input)).toThrow('Invalid candle response');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { SavedCandle } from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../application/instrument-market';
|
||||||
|
|
||||||
|
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toSavedCandle };
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { BondIncomeService } from './bonds/application/bond-income.service';
|
|
||||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
|
||||||
+16
-16
@@ -23,8 +23,8 @@ export class GetStockCompanyUseCase {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
{
|
? {
|
||||||
description: result.values.description ?? null,
|
description: result.values.description ?? null,
|
||||||
metrics: metrics(
|
metrics: metrics(
|
||||||
result.values.fundamentals ?? null,
|
result.values.fundamentals ?? null,
|
||||||
@@ -32,8 +32,8 @@ export class GetStockCompanyUseCase {
|
|||||||
),
|
),
|
||||||
unavailable: result.unavailable,
|
unavailable: result.unavailable,
|
||||||
observedAt: result.observedAt,
|
observedAt: result.observedAt,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -51,22 +51,22 @@ export function metrics(
|
|||||||
][] = [
|
][] = [
|
||||||
['marketCapitalization', 'money', null],
|
['marketCapitalization', 'money', null],
|
||||||
['peRatioTtm', 'ratio', 'TTM'],
|
['peRatioTtm', 'ratio', 'TTM'],
|
||||||
...(bank ?
|
...(bank
|
||||||
([
|
? ([
|
||||||
['priceToBookTtm', 'ratio', 'TTM'],
|
['priceToBookTtm', 'ratio', 'TTM'],
|
||||||
['roe', 'percent', null],
|
['roe', 'percent', null],
|
||||||
] as typeof selection) :
|
] as typeof selection)
|
||||||
([
|
: ([
|
||||||
['revenueTtm', 'money', 'TTM'],
|
['revenueTtm', 'money', 'TTM'],
|
||||||
['ebitdaTtm', 'money', 'TTM'],
|
['ebitdaTtm', 'money', 'TTM'],
|
||||||
] as typeof selection)),
|
] as typeof selection)),
|
||||||
['netIncomeTtm', 'money', 'TTM'],
|
['netIncomeTtm', 'money', 'TTM'],
|
||||||
...(!bank ?
|
...(!bank
|
||||||
([
|
? ([
|
||||||
['totalDebtMrq', 'money', 'MRQ'],
|
['totalDebtMrq', 'money', 'MRQ'],
|
||||||
['netDebtToEbitda', 'ratio', null],
|
['netDebtToEbitda', 'ratio', null],
|
||||||
] as typeof selection) :
|
] as typeof selection)
|
||||||
[]),
|
: []),
|
||||||
];
|
];
|
||||||
return selection.flatMap(([key, unit, period]) => {
|
return selection.flatMap(([key, unit, period]) => {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
||||||
@@ -74,8 +74,8 @@ export function metrics(
|
|||||||
const currency = values.currency?.toUpperCase() || null;
|
const currency = values.currency?.toUpperCase() || null;
|
||||||
return typeof value === 'number' &&
|
return typeof value === 'number' &&
|
||||||
Number.isFinite(value) &&
|
Number.isFinite(value) &&
|
||||||
(unit !== 'money' || currency) ?
|
(unit !== 'money' || currency)
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
@@ -83,7 +83,7 @@ export function metrics(
|
|||||||
period,
|
period,
|
||||||
currency: unit === 'money' ? currency : null,
|
currency: unit === 'money' ? currency : null,
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[];
|
: [];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,9 +34,9 @@ export class StockCatalogService {
|
|||||||
data:
|
data:
|
||||||
result.data?.map((item) => {
|
result.data?.map((item) => {
|
||||||
const short = stock(item);
|
const short = stock(item);
|
||||||
const detail = item.detail ?
|
const detail = item.detail
|
||||||
stock({ ...item.detail, uid: item.uid }) :
|
? stock({ ...item.detail, uid: item.uid })
|
||||||
short;
|
: short;
|
||||||
const last = item.price;
|
const last = item.price;
|
||||||
return {
|
return {
|
||||||
...detail,
|
...detail,
|
||||||
@@ -44,12 +44,12 @@ export class StockCatalogService {
|
|||||||
isin: detail.isin ?? short.isin,
|
isin: detail.isin ?? short.isin,
|
||||||
apiTradeAvailable: short.apiTradeAvailable,
|
apiTradeAvailable: short.apiTradeAvailable,
|
||||||
price:
|
price:
|
||||||
last?.price && detail.currency ?
|
last?.price && detail.currency
|
||||||
{
|
? {
|
||||||
currency: detail.currency,
|
currency: detail.currency,
|
||||||
value: formatDecimal(decimal(last.price)),
|
value: formatDecimal(decimal(last.price)),
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
priceAt: timestamp(last?.time),
|
priceAt: timestamp(last?.time),
|
||||||
};
|
};
|
||||||
}) ?? null,
|
}) ?? null,
|
||||||
@@ -64,9 +64,9 @@ export class StockCatalogService {
|
|||||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...stock(result.data), slug: result.data.slug } :
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -83,9 +83,9 @@ export class StockCatalogService {
|
|||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data ?
|
data: result.data
|
||||||
{ ...stock(result.data), slug: result.data.slug } :
|
? { ...stock(result.data), slug: result.data.slug }
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
|
|||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -69,18 +70,18 @@ export class StockIncomeService {
|
|||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const events = sources[1].payload?.dividends;
|
const events = sources[1].payload?.dividends;
|
||||||
const position = portfolio.data ?
|
const position = portfolio.data
|
||||||
instrumentPosition(portfolio.data, uid, 'STOCK') :
|
? instrumentPosition(portfolio.data, uid, 'STOCK')
|
||||||
null;
|
: null;
|
||||||
const projected = events ?
|
const projected = events
|
||||||
stockDividends(
|
? stockDividends(
|
||||||
events,
|
events,
|
||||||
position,
|
position,
|
||||||
{ from, to, today },
|
{ from, to, today },
|
||||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
!portfolio.data,
|
!portfolio.data,
|
||||||
) :
|
)
|
||||||
undefined;
|
: undefined;
|
||||||
const result = await this.forecasts.result({
|
const result = await this.forecasts.result({
|
||||||
userId,
|
userId,
|
||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
@@ -91,9 +92,13 @@ export class StockIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
return result.data || !projected ?
|
if (result.data?.positionAt) {
|
||||||
result :
|
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||||
{
|
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||||
|
}
|
||||||
|
return result.data || !projected
|
||||||
|
? result
|
||||||
|
: {
|
||||||
data: stockDividends(
|
data: stockDividends(
|
||||||
events ?? [],
|
events ?? [],
|
||||||
null,
|
null,
|
||||||
@@ -117,9 +122,9 @@ export function stockDividends(
|
|||||||
const quantity =
|
const quantity =
|
||||||
position &&
|
position &&
|
||||||
position.accounts.length &&
|
position.accounts.length &&
|
||||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
|
||||||
decimalFromString(position.quantity) :
|
? decimalFromString(position.quantity)
|
||||||
null;
|
: null;
|
||||||
const dividends: StockDividendDto[] = events
|
const dividends: StockDividendDto[] = events
|
||||||
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
.filter((event) => event.dividendType?.toLowerCase() !== 'cancelled')
|
||||||
.map((event) => {
|
.map((event) => {
|
||||||
@@ -128,29 +133,29 @@ export function stockDividends(
|
|||||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
const currency = event.dividendNet?.currency?.toUpperCase();
|
||||||
const perShare =
|
const perShare =
|
||||||
currency && event.dividendNet ?
|
currency && event.dividendNet
|
||||||
{ currency, value: formatDecimal(decimal(event.dividendNet)) } :
|
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
||||||
null;
|
: null;
|
||||||
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
||||||
const eventDate = paymentDate ?? recordDate;
|
const eventDate = paymentDate ?? recordDate;
|
||||||
const timing: StockDividendDto['timing'] = eventDate ?
|
const timing: StockDividendDto['timing'] = eventDate
|
||||||
eventDate >= today ?
|
? eventDate >= today
|
||||||
'upcoming' :
|
? 'upcoming'
|
||||||
'past' :
|
: 'past'
|
||||||
'undated';
|
: 'undated';
|
||||||
const estimatedAmount =
|
const estimatedAmount =
|
||||||
quantity &&
|
quantity &&
|
||||||
perShare &&
|
perShare &&
|
||||||
recordDate &&
|
recordDate &&
|
||||||
recordDate >= today &&
|
recordDate >= today &&
|
||||||
timing === 'upcoming' ?
|
timing === 'upcoming'
|
||||||
{
|
? {
|
||||||
currency: perShare.currency,
|
currency: perShare.currency,
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perShare.value)),
|
multiply(quantity, decimalFromString(perShare.value)),
|
||||||
),
|
),
|
||||||
} :
|
}
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
perShare,
|
perShare,
|
||||||
recordDate,
|
recordDate,
|
||||||
|
|||||||
@@ -44,9 +44,9 @@ export class StockMarketService {
|
|||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument ?
|
data: result.values.instrument
|
||||||
stockQuote(result.values, result.unavailable, result.observedAt) :
|
? stockQuote(result.values, result.unavailable, result.observedAt)
|
||||||
null,
|
: null,
|
||||||
meta: result.meta,
|
meta: result.meta,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -75,20 +75,20 @@ export function stockQuote(
|
|||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const currency = share?.currency?.toUpperCase();
|
const currency = share?.currency?.toUpperCase();
|
||||||
const price =
|
const price =
|
||||||
last?.price && currency ?
|
last?.price && currency
|
||||||
{ currency, value: formatDecimal(decimal(last.price)) } :
|
? { currency, value: formatDecimal(decimal(last.price)) }
|
||||||
null;
|
: null;
|
||||||
const previousPrice =
|
const previousPrice =
|
||||||
previous?.close && currency ?
|
previous?.close && currency
|
||||||
{ currency, value: formatDecimal(decimal(previous.close)) } :
|
? { currency, value: formatDecimal(decimal(previous.close)) }
|
||||||
null;
|
: null;
|
||||||
const difference =
|
const difference =
|
||||||
price && previousPrice ?
|
price && previousPrice
|
||||||
subtract(
|
? subtract(
|
||||||
decimalFromString(price.value),
|
decimalFromString(price.value),
|
||||||
decimalFromString(previousPrice.value),
|
decimalFromString(previousPrice.value),
|
||||||
) :
|
)
|
||||||
null;
|
: null;
|
||||||
return {
|
return {
|
||||||
price,
|
price,
|
||||||
priceAt,
|
priceAt,
|
||||||
@@ -96,21 +96,21 @@ export function stockQuote(
|
|||||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||||
previousClose: previousPrice,
|
previousClose: previousPrice,
|
||||||
change:
|
change:
|
||||||
difference && currency ?
|
difference && currency
|
||||||
{ currency, value: formatDecimal(difference) } :
|
? { currency, value: formatDecimal(difference) }
|
||||||
null,
|
: null,
|
||||||
changePercent:
|
changePercent:
|
||||||
difference &&
|
difference &&
|
||||||
previousPrice &&
|
previousPrice &&
|
||||||
decimalFromString(previousPrice.value).value > ZERO_AMOUNT ?
|
decimalFromString(previousPrice.value).value > ZERO_AMOUNT
|
||||||
formatDecimal(
|
? formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(difference, decimalFromString('100')),
|
multiply(difference, decimalFromString('100')),
|
||||||
decimalFromString(previousPrice.value),
|
decimalFromString(previousPrice.value),
|
||||||
CHANGE_PERCENT_DECIMAL_PLACES,
|
CHANGE_PERCENT_DECIMAL_PLACES,
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
unavailable,
|
unavailable,
|
||||||
observedAt,
|
observedAt,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { IsIn, IsOptional } from 'class-validator';
|
|||||||
|
|
||||||
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
||||||
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||||
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
|
export const syncStateValues = ['idle', 'running', 'failed'] as const;
|
||||||
|
|
||||||
export class InvestmentReadQueryDto {
|
export class InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['observe'] })
|
@ApiPropertyOptional({ enum: ['observe'] })
|
||||||
@@ -61,9 +61,6 @@ export class InvestmentDependencyDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare dataRevision: string;
|
declare dataRevision: string;
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
declare stateVersion: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String })
|
@ApiPropertyOptional({ type: String })
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ describe('investment resource source freshness', () => {
|
|||||||
const stale = investmentResourceMeta(input);
|
const stale = investmentResourceMeta(input);
|
||||||
expect(stale.freshness).toBe('stale');
|
expect(stale.freshness).toBe('stale');
|
||||||
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
||||||
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
@@ -69,7 +68,6 @@ describe('dependent resource lifecycle', () => {
|
|||||||
});
|
});
|
||||||
expect(terminal.sync).toEqual(failed.sync);
|
expect(terminal.sync).toEqual(failed.sync);
|
||||||
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
||||||
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||||
|
|
||||||
export function investmentResourceMeta(input: {
|
export function investmentResourceMeta(input: {
|
||||||
@@ -16,11 +14,11 @@ export function investmentResourceMeta(input: {
|
|||||||
const ownSync = {
|
const ownSync = {
|
||||||
id: input.work?.id ?? null,
|
id: input.work?.id ?? null,
|
||||||
state:
|
state:
|
||||||
input.work?.state === 'running' ?
|
input.work?.state === 'running'
|
||||||
('running' as const) :
|
? ('running' as const)
|
||||||
input.work?.state === 'failed' ?
|
: input.work?.state === 'failed'
|
||||||
('failed' as const) :
|
? ('failed' as const)
|
||||||
('idle' as const),
|
: ('idle' as const),
|
||||||
errorCode: input.work?.errorCode ?? null,
|
errorCode: input.work?.errorCode ?? null,
|
||||||
};
|
};
|
||||||
const states = [
|
const states = [
|
||||||
@@ -31,14 +29,13 @@ export function investmentResourceMeta(input: {
|
|||||||
];
|
];
|
||||||
const sync =
|
const sync =
|
||||||
states.find((state) => state.state === 'running') ??
|
states.find((state) => state.state === 'running') ??
|
||||||
states.find((state) => state.state === 'queued') ??
|
|
||||||
states.find((state) => state.state === 'failed') ??
|
states.find((state) => state.state === 'failed') ??
|
||||||
ownSync;
|
ownSync;
|
||||||
const sourceFreshness = input.fetchedAt ?
|
const sourceFreshness = input.fetchedAt
|
||||||
Date.now() - input.fetchedAt.valueOf() < input.freshForMs ?
|
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
|
||||||
('fresh' as const) :
|
? ('fresh' as const)
|
||||||
('stale' as const) :
|
: ('stale' as const)
|
||||||
('unknown' as const);
|
: ('unknown' as const);
|
||||||
const freshness = [
|
const freshness = [
|
||||||
sourceFreshness,
|
sourceFreshness,
|
||||||
...(input.dependencies ?? [])
|
...(input.dependencies ?? [])
|
||||||
@@ -56,34 +53,20 @@ export function investmentResourceMeta(input: {
|
|||||||
.sort();
|
.sort();
|
||||||
const meta = {
|
const meta = {
|
||||||
resourceKey: input.resourceKey,
|
resourceKey: input.resourceKey,
|
||||||
...(input.unavailableReason ?
|
...(input.unavailableReason
|
||||||
{ unavailableReason: input.unavailableReason } :
|
? { unavailableReason: input.unavailableReason }
|
||||||
{}),
|
: {}),
|
||||||
dataRevision: input.dataRevision,
|
dataRevision: input.dataRevision,
|
||||||
availability: input.availability,
|
availability: input.availability,
|
||||||
freshness: freshness.includes('unknown') ?
|
freshness: freshness.includes('unknown')
|
||||||
('unknown' as const) :
|
? ('unknown' as const)
|
||||||
freshness.includes('stale') ?
|
: freshness.includes('stale')
|
||||||
('stale' as const) :
|
? ('stale' as const)
|
||||||
('fresh' as const),
|
: ('fresh' as const),
|
||||||
lastSuccessfulAt: timestamps[0] ?? null,
|
lastSuccessfulAt: timestamps[0] ?? null,
|
||||||
sync,
|
sync,
|
||||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||||
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||||
};
|
};
|
||||||
return {
|
return meta;
|
||||||
...meta,
|
|
||||||
stateVersion: createHash('sha256')
|
|
||||||
.update(
|
|
||||||
JSON.stringify({
|
|
||||||
...meta,
|
|
||||||
// The requested window is identified by resourceKey. Its moving "now"
|
|
||||||
// boundary is not a change to saved coverage between status and GET.
|
|
||||||
coverage: meta.coverage ?
|
|
||||||
{ ...meta.coverage, requested: undefined } :
|
|
||||||
undefined,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.digest('base64url'),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,16 +84,16 @@ export function planInvestmentScreen(
|
|||||||
const unavailable: { reason: string; resourceKey: string }[] = [];
|
const unavailable: { reason: string; resourceKey: string }[] = [];
|
||||||
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
||||||
const targets = accounts.flatMap((account) =>
|
const targets = accounts.flatMap((account) =>
|
||||||
from < to ?
|
from < to
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
...(instrumentUid ? { instrumentUid } : {}),
|
...(instrumentUid ? { instrumentUid } : {}),
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
sources.push(
|
sources.push(
|
||||||
...operationIntervals(targets).map((range) => ({
|
...operationIntervals(targets).map((range) => ({
|
||||||
@@ -112,12 +112,12 @@ export function planInvestmentScreen(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const knownOpening =
|
const knownOpening =
|
||||||
accounts.length && accounts.every((account) => account.openedOn) ?
|
accounts.length && accounts.every((account) => account.openedOn)
|
||||||
new Date(
|
? new Date(
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
||||||
) :
|
)
|
||||||
undefined;
|
: undefined;
|
||||||
if (!context.membershipKnown) return { sources, unavailable, now };
|
if (!context.membershipKnown) return { sources, unavailable, now };
|
||||||
if (
|
if (
|
||||||
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
||||||
@@ -146,9 +146,9 @@ export function planInvestmentScreen(
|
|||||||
);
|
);
|
||||||
for (const range of [
|
for (const range of [
|
||||||
period.selected,
|
period.selected,
|
||||||
...(screen.screen === 'income' ?
|
...(screen.screen === 'income'
|
||||||
[period.previous, ...(period.yearAgo ? [period.yearAgo] : [])] :
|
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
||||||
[]),
|
: []),
|
||||||
]) {
|
]) {
|
||||||
const bounds = dateOnlyBounds(range, now);
|
const bounds = dateOnlyBounds(range, now);
|
||||||
operations(bounds.from, bounds.to);
|
operations(bounds.from, bounds.to);
|
||||||
@@ -216,11 +216,11 @@ export function planInvestmentScreen(
|
|||||||
}
|
}
|
||||||
const range =
|
const range =
|
||||||
screen.candleRange ??
|
screen.candleRange ??
|
||||||
(period === 'all' ?
|
(period === 'all'
|
||||||
instrument.historyFrom ?
|
? instrument.historyFrom
|
||||||
{ from: instrument.historyFrom, to: now } :
|
? { from: instrument.historyFrom, to: now }
|
||||||
null :
|
: null
|
||||||
{ from, to: now });
|
: { from, to: now });
|
||||||
if (range) {
|
if (range) {
|
||||||
sources.push({
|
sources.push({
|
||||||
...identity,
|
...identity,
|
||||||
@@ -237,17 +237,17 @@ export function planInvestmentScreen(
|
|||||||
}
|
}
|
||||||
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
|
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
|
||||||
const day =
|
const day =
|
||||||
screen.screen === 'stock' || screen.screen === 'bond' ?
|
screen.screen === 'stock' || screen.screen === 'bond'
|
||||||
moscowDate(now) :
|
? moscowDate(now)
|
||||||
now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
: now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||||
const from =
|
const from =
|
||||||
screen.screen === 'events' && screen.from ?
|
screen.screen === 'events' && screen.from
|
||||||
new Date(`${screen.from}T00:00:00Z`) :
|
? new Date(`${screen.from}T00:00:00Z`)
|
||||||
new Date(`${day}T00:00:00Z`);
|
: new Date(`${day}T00:00:00Z`);
|
||||||
const to =
|
const to =
|
||||||
screen.screen === 'events' && screen.to ?
|
screen.screen === 'events' && screen.to
|
||||||
new Date(`${screen.to}T00:00:00Z`) :
|
? new Date(`${screen.to}T00:00:00Z`)
|
||||||
new Date(from);
|
: new Date(from);
|
||||||
if (
|
if (
|
||||||
!(screen.screen === 'events' && screen.to) &&
|
!(screen.screen === 'events' && screen.to) &&
|
||||||
screen.screen !== 'stock' &&
|
screen.screen !== 'stock' &&
|
||||||
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
|
|||||||
priority: 'screen',
|
priority: 'screen',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!force &&
|
|
||||||
['account', 'events', 'income', 'operations'].includes(screen.screen)
|
|
||||||
) {
|
|
||||||
if (instrument.historyFrom) {
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: 'candles',
|
|
||||||
from: instrument.historyFrom,
|
|
||||||
to: now,
|
|
||||||
priority: 'history',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unavailable.push({
|
|
||||||
resourceKey: `candles/${instrument.uid}/all`,
|
|
||||||
reason: 'history_start_unavailable',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
||||||
sources.push(
|
sources.push(
|
||||||
@@ -309,9 +290,9 @@ export function accountHistory(
|
|||||||
): OperationInterval[] {
|
): OperationInterval[] {
|
||||||
return accounts.flatMap((account) => {
|
return accounts.flatMap((account) => {
|
||||||
const from = new Date(
|
const from = new Date(
|
||||||
account.openedOn ?
|
account.openedOn
|
||||||
`${account.openedOn}T00:00:00Z` :
|
? `${account.openedOn}T00:00:00Z`
|
||||||
'1970-01-01T00:00:00Z',
|
: '1970-01-01T00:00:00Z',
|
||||||
);
|
);
|
||||||
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
|
return from < now ? [{ accountId: account.accountId, from, to: now }] : [];
|
||||||
});
|
});
|
||||||
@@ -324,22 +305,22 @@ export function ledgerRange(
|
|||||||
accounts: Account[],
|
accounts: Account[],
|
||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
const opening = accounts.every((account) => account.openedOn) ?
|
const opening = accounts.every((account) => account.openedOn)
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
accounts.map((account) => account.openedOn ?? '').sort()[0] :
|
? accounts.map((account) => account.openedOn ?? '').sort()[0]
|
||||||
undefined;
|
: undefined;
|
||||||
const from = fromValue ?
|
const from = fromValue
|
||||||
new Date(fromValue) :
|
? new Date(fromValue)
|
||||||
instrumentUid ?
|
: instrumentUid
|
||||||
new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z') :
|
? new Date(opening ? `${opening}T00:00:00Z` : '1970-01-01T00:00:00Z')
|
||||||
new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
|
: new Date(Date.UTC(now.getUTCFullYear(), JANUARY_INDEX, FIRST_DAY_OF_MONTH));
|
||||||
const end = toValue ?
|
const end = toValue
|
||||||
new Date(
|
? new Date(
|
||||||
/^\d{4}-\d{2}-\d{2}$/.test(toValue) ?
|
/^\d{4}-\d{2}-\d{2}$/.test(toValue)
|
||||||
`${toValue}T23:59:59.999Z` :
|
? `${toValue}T23:59:59.999Z`
|
||||||
toValue,
|
: toValue,
|
||||||
) :
|
)
|
||||||
now;
|
: now;
|
||||||
return {
|
return {
|
||||||
from,
|
from,
|
||||||
to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
|
to: new Date(Math.min(end.valueOf() + (toValue ? EXCLUSIVE_END_OFFSET_MS : NO_END_OFFSET_MS), now.valueOf())),
|
||||||
|
|||||||
+16
-17
@@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import {
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
TbankAccessService,
|
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
TbankClient,
|
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { accountHistory } from '../../investment-sync-plan';
|
import { accountHistory } from '../../investment-sync-plan';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
@@ -82,24 +81,24 @@ export class BrokerageOperationsSyncService {
|
|||||||
...intervals.map((interval) => interval.to.valueOf()),
|
...intervals.map((interval) => interval.to.valueOf()),
|
||||||
);
|
);
|
||||||
const targets = intervals.map((interval) =>
|
const targets = intervals.map((interval) =>
|
||||||
options.movingEnd && interval.to.valueOf() === movingTo ?
|
options.movingEnd && interval.to.valueOf() === movingTo
|
||||||
{ ...interval, to: now } :
|
? { ...interval, to: now }
|
||||||
interval,
|
: interval,
|
||||||
);
|
);
|
||||||
const plan = planOperationRead(targets, coverage, now, options);
|
const plan = planOperationRead(targets, coverage, now, options);
|
||||||
const complete = plan.intervals.every(
|
const complete = plan.intervals.every(
|
||||||
(interval) => interval.availability === 'complete',
|
(interval) => interval.availability === 'complete',
|
||||||
);
|
);
|
||||||
const handle =
|
const handle =
|
||||||
!options.observe && plan.load.length ?
|
!options.observe && plan.load.length
|
||||||
await this.request(
|
? await this.request(
|
||||||
userId,
|
userId,
|
||||||
plan.load,
|
plan.load,
|
||||||
complete || Boolean(options.force),
|
complete || Boolean(options.force),
|
||||||
'screen',
|
'screen',
|
||||||
connection.revision,
|
connection.revision,
|
||||||
) :
|
)
|
||||||
null;
|
: null;
|
||||||
if (!options.observe && !options.force && options.history) {
|
if (!options.observe && !options.force && options.history) {
|
||||||
const accountIds = new Set(
|
const accountIds = new Set(
|
||||||
intervals.map((interval) => interval.accountId),
|
intervals.map((interval) => interval.accountId),
|
||||||
@@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService {
|
|||||||
this.logger.event(
|
this.logger.event(
|
||||||
'error',
|
'error',
|
||||||
'tbank.operations.sync.persistence_failed',
|
'tbank.operations.sync.persistence_failed',
|
||||||
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
|
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const next = pending ? await this.repository.pending(attempt) : null;
|
const next = pending ? await this.repository.pending(attempt) : null;
|
||||||
@@ -267,12 +266,12 @@ export class BrokerageOperationsSyncService {
|
|||||||
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS,
|
Math.ceil(interval.to.valueOf() / MILLISECONDS_PER_SECOND) + UPSTREAM_RANGE_MARGIN_SECONDS,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
...(interval.instrumentUid ?
|
...(interval.instrumentUid
|
||||||
{ instrumentId: interval.instrumentUid } :
|
? { instrumentId: interval.instrumentUid }
|
||||||
{}),
|
: {}),
|
||||||
cursor,
|
cursor,
|
||||||
limit: operationsPageSize,
|
limit: operationsPageSize,
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
state: TbankOperationState.Executed,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||||
@@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.repository.fail(
|
await this.repository.fail(
|
||||||
attempt,
|
attempt,
|
||||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ import {
|
|||||||
ledgerRange,
|
ledgerRange,
|
||||||
planInvestmentScreen,
|
planInvestmentScreen,
|
||||||
} from '../../investment-sync-plan';
|
} from '../../investment-sync-plan';
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
|
confirmedOperationRanges,
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
@@ -86,13 +88,13 @@ export class BrokerageOperationsService {
|
|||||||
const filterKey = JSON.stringify([
|
const filterKey = JSON.stringify([
|
||||||
accountId,
|
accountId,
|
||||||
from.toISOString(),
|
from.toISOString(),
|
||||||
query.to ?
|
query.to
|
||||||
new Date(
|
? new Date(
|
||||||
/^\d{4}-\d{2}-\d{2}$/.test(query.to) ?
|
/^\d{4}-\d{2}-\d{2}$/.test(query.to)
|
||||||
`${query.to}T23:59:59.999Z` :
|
? `${query.to}T23:59:59.999Z`
|
||||||
query.to,
|
: query.to,
|
||||||
).toISOString() :
|
).toISOString()
|
||||||
'now',
|
: 'now',
|
||||||
query.instrumentUid ?? null,
|
query.instrumentUid ?? null,
|
||||||
categories,
|
categories,
|
||||||
limit,
|
limit,
|
||||||
@@ -129,9 +131,9 @@ export class BrokerageOperationsService {
|
|||||||
source.priority === 'screen' &&
|
source.priority === 'screen' &&
|
||||||
source.accountId &&
|
source.accountId &&
|
||||||
source.from &&
|
source.from &&
|
||||||
source.to ?
|
source.to
|
||||||
[{ accountId: source.accountId, from: source.from, to: source.to }] :
|
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
if (query.sync !== 'observe') {
|
if (query.sync !== 'observe') {
|
||||||
const discovery = await this.membership.request(
|
const discovery = await this.membership.request(
|
||||||
@@ -159,9 +161,9 @@ export class BrokerageOperationsService {
|
|||||||
accountId === 'all' || account.accountId === accountId,
|
accountId === 'all' || account.accountId === accountId,
|
||||||
)
|
)
|
||||||
.flatMap((account) =>
|
.flatMap((account) =>
|
||||||
currentRange.from < currentRange.to ?
|
currentRange.from < currentRange.to
|
||||||
[{ accountId: account.accountId, ...currentRange }] :
|
? [{ accountId: account.accountId, ...currentRange }]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
await this.sync.readPlan(userId, targets, {
|
await this.sync.readPlan(userId, targets, {
|
||||||
movingEnd,
|
movingEnd,
|
||||||
@@ -181,36 +183,21 @@ export class BrokerageOperationsService {
|
|||||||
const saved = await this.repository.readPage(readInput);
|
const saved = await this.repository.readPage(readInput);
|
||||||
const accounts = saved?.connection.accounts ?? [];
|
const accounts = saved?.connection.accounts ?? [];
|
||||||
const targets = accounts.flatMap((account) =>
|
const targets = accounts.flatMap((account) =>
|
||||||
from < to ?
|
from < to
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
instrumentUid: query.instrumentUid,
|
instrumentUid: query.instrumentUid,
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||||
movingEnd,
|
movingEnd,
|
||||||
});
|
});
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
||||||
(saved?.coverage ?? [])
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
(!row.instrumentUid ||
|
|
||||||
row.instrumentUid === target.instrumentUid) &&
|
|
||||||
row.from < target.to &&
|
|
||||||
target.from < row.to,
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const missing = uncoveredOperationIntervals(
|
const missing = uncoveredOperationIntervals(
|
||||||
plan.intervals,
|
plan.intervals,
|
||||||
confirmed.map((range) => ({
|
confirmed.map((range) => ({
|
||||||
@@ -296,17 +283,17 @@ export class BrokerageOperationsService {
|
|||||||
const meta = investmentResourceMeta({
|
const meta = investmentResourceMeta({
|
||||||
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
|
resourceKey: `operations/${createHash('sha256').update(filterKey).digest('base64url')}`,
|
||||||
dataRevision: saved?.binding ?? 'missing',
|
dataRevision: saved?.binding ?? 'missing',
|
||||||
availability: !available ?
|
availability: !available
|
||||||
'missing' :
|
? 'missing'
|
||||||
complete ?
|
: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
confirmed.length ?
|
: confirmed.length
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
fetchedAt: lastSuccessfulAt,
|
fetchedAt: lastSuccessfulAt,
|
||||||
freshForMs: plan.intervals.some((interval) => interval.stale) ?
|
freshForMs: plan.intervals.some((interval) => interval.stale)
|
||||||
STALE_FRESHNESS_MS :
|
? STALE_FRESHNESS_MS
|
||||||
Number.POSITIVE_INFINITY,
|
: Number.POSITIVE_INFINITY,
|
||||||
work: work ?? saved?.membershipWork ?? null,
|
work: work ?? saved?.membershipWork ?? null,
|
||||||
dependencies: [membershipMeta, ...metadataDependencies],
|
dependencies: [membershipMeta, ...metadataDependencies],
|
||||||
coverage: {
|
coverage: {
|
||||||
@@ -320,8 +307,8 @@ export class BrokerageOperationsService {
|
|||||||
(interval) =>
|
(interval) =>
|
||||||
interval.accountId === account.accountId && interval.asOf,
|
interval.accountId === account.accountId && interval.asOf,
|
||||||
),
|
),
|
||||||
) ?
|
)
|
||||||
new Date(
|
? new Date(
|
||||||
Math.min(
|
Math.min(
|
||||||
...accounts.map((account) =>
|
...accounts.map((account) =>
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -334,42 +321,42 @@ export class BrokerageOperationsService {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).toISOString() :
|
).toISOString()
|
||||||
null,
|
: null,
|
||||||
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
||||||
historyState: saved?.work.some(
|
historyState: saved?.work.some(
|
||||||
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
||||||
) ?
|
)
|
||||||
'loading' :
|
? 'loading'
|
||||||
saved?.work.some(
|
: saved?.work.some(
|
||||||
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
||||||
) ?
|
)
|
||||||
'failed' :
|
? 'failed'
|
||||||
knownBoundary &&
|
: knownBoundary &&
|
||||||
history.intervals.every(
|
history.intervals.every(
|
||||||
(interval) => interval.availability === 'complete',
|
(interval) => interval.availability === 'complete',
|
||||||
) ?
|
)
|
||||||
'complete' :
|
? 'complete'
|
||||||
saved?.coverage.length ?
|
: saved?.coverage.length
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const last = rows.at(LAST_ROW_INDEX);
|
const last = rows.at(LAST_ROW_INDEX);
|
||||||
const hasNext = Boolean(saved && saved.rows.length > limit);
|
const hasNext = Boolean(saved && saved.rows.length > limit);
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
meta.availability === 'missing' ?
|
meta.availability === 'missing'
|
||||||
null :
|
? null
|
||||||
{
|
: {
|
||||||
accountId,
|
accountId,
|
||||||
items: rows.map((row) => ({
|
items: rows.map((row) => ({
|
||||||
...mapBrokerageOperation(
|
...mapBrokerageOperation(
|
||||||
row.payload as TbankOperationItem,
|
row.payload as TbankOperationItem,
|
||||||
row.occurredAt,
|
row.occurredAt,
|
||||||
),
|
),
|
||||||
...(accountId === 'all' ?
|
...(accountId === 'all'
|
||||||
{
|
? {
|
||||||
account: {
|
account: {
|
||||||
id: row.accountId,
|
id: row.accountId,
|
||||||
name:
|
name:
|
||||||
@@ -377,8 +364,8 @@ export class BrokerageOperationsService {
|
|||||||
(account) => account.accountId === row.accountId,
|
(account) => account.accountId === row.accountId,
|
||||||
)?.name ?? row.accountId,
|
)?.name ?? row.accountId,
|
||||||
},
|
},
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
logoName:
|
logoName:
|
||||||
instruments
|
instruments
|
||||||
.get(metadataKey(row.payload as TbankOperationItem))
|
.get(metadataKey(row.payload as TbankOperationItem))
|
||||||
@@ -386,16 +373,16 @@ export class BrokerageOperationsService {
|
|||||||
})),
|
})),
|
||||||
hasNext,
|
hasNext,
|
||||||
nextCursor:
|
nextCursor:
|
||||||
hasNext && last && saved ?
|
hasNext && last && saved
|
||||||
this.encodeCursor({
|
? this.encodeCursor({
|
||||||
binding: saved.binding,
|
binding: saved.binding,
|
||||||
movingEnd,
|
movingEnd,
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
at: last.occurredAt.toISOString(),
|
at: last.occurredAt.toISOString(),
|
||||||
accountId: last.accountId,
|
accountId: last.accountId,
|
||||||
id: last.brokerOperationId,
|
id: last.brokerOperationId,
|
||||||
}) :
|
})
|
||||||
null,
|
: null,
|
||||||
observedAt: now.toISOString(),
|
observedAt: now.toISOString(),
|
||||||
},
|
},
|
||||||
meta,
|
meta,
|
||||||
@@ -442,7 +429,7 @@ export class BrokerageOperationsService {
|
|||||||
}
|
}
|
||||||
return cursor as LocalOperationsCursor;
|
return cursor as LocalOperationsCursor;
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -543,30 +530,18 @@ export class BrokerageOperationsService {
|
|||||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||||
(complete ? saved?.connection.membershipAt : null) ??
|
(complete ? saved?.connection.membershipAt : null) ??
|
||||||
null;
|
null;
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, sources);
|
||||||
sources
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
operationIntervalsOverlap(row, target),
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const meta = investmentResourceMeta({
|
const meta = investmentResourceMeta({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
||||||
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
||||||
dataRevision: saved?.binding ?? 'missing',
|
dataRevision: saved?.binding ?? 'missing',
|
||||||
availability: !known ?
|
availability: !known
|
||||||
'missing' :
|
? 'missing'
|
||||||
complete ?
|
: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
sources.length ?
|
: sources.length
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
||||||
work,
|
work,
|
||||||
@@ -579,8 +554,8 @@ export class BrokerageOperationsService {
|
|||||||
confirmed,
|
confirmed,
|
||||||
).map(serialize),
|
).map(serialize),
|
||||||
asOf:
|
asOf:
|
||||||
complete && targets.length ?
|
complete && targets.length
|
||||||
new Date(
|
? new Date(
|
||||||
Math.min(
|
Math.min(
|
||||||
...targets.map((target) =>
|
...targets.map((target) =>
|
||||||
Math.max(
|
Math.max(
|
||||||
@@ -595,24 +570,24 @@ export class BrokerageOperationsService {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
).toISOString() :
|
).toISOString()
|
||||||
null,
|
: null,
|
||||||
historyBoundary: (saved?.connection.accounts ?? []).every(
|
historyBoundary: (saved?.connection.accounts ?? []).every(
|
||||||
(account) => account.openedOn,
|
(account) => account.openedOn,
|
||||||
) ?
|
)
|
||||||
'known' :
|
? 'known'
|
||||||
'unknown',
|
: 'unknown',
|
||||||
historyState: complete ?
|
historyState: complete
|
||||||
'complete' :
|
? 'complete'
|
||||||
sources.length ?
|
: sources.length
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
items: complete ?
|
items: complete
|
||||||
(saved?.rows ?? [])
|
? (saved?.rows ?? [])
|
||||||
.filter((row) =>
|
.filter((row) =>
|
||||||
plan.intervals.some(
|
plan.intervals.some(
|
||||||
(interval) =>
|
(interval) =>
|
||||||
@@ -626,8 +601,8 @@ export class BrokerageOperationsService {
|
|||||||
row.payload as TbankOperationItem,
|
row.payload as TbankOperationItem,
|
||||||
row.occurredAt,
|
row.occurredAt,
|
||||||
),
|
),
|
||||||
...(accountId === 'all' ?
|
...(accountId === 'all'
|
||||||
{
|
? {
|
||||||
account: {
|
account: {
|
||||||
id: row.accountId,
|
id: row.accountId,
|
||||||
name:
|
name:
|
||||||
@@ -635,10 +610,10 @@ export class BrokerageOperationsService {
|
|||||||
(account) => account.accountId === row.accountId,
|
(account) => account.accountId === row.accountId,
|
||||||
)?.name ?? row.accountId,
|
)?.name ?? row.accountId,
|
||||||
},
|
},
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
})) :
|
}))
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export enum OperationErrorCode {
|
||||||
|
InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR',
|
||||||
|
}
|
||||||
@@ -1,4 +1,30 @@
|
|||||||
import { operationIntervals, planOperationRead } from './operations-sync-plan';
|
import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan';
|
||||||
|
|
||||||
|
describe('confirmed operation range projection', () => {
|
||||||
|
it('clips overlaps in source order without merging and respects account and instrument scope', () => {
|
||||||
|
const day = (value: number) => new Date(Date.UTC(2026, 0, value));
|
||||||
|
const target = { accountId: 'a', from: day(2), to: day(5) };
|
||||||
|
const sources = [
|
||||||
|
{ ...target, from: day(3), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(4) },
|
||||||
|
{ ...target, instrumentUid: 'uid' },
|
||||||
|
{ ...target, accountId: 'other' },
|
||||||
|
{ ...target, from: day(5), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(2) },
|
||||||
|
];
|
||||||
|
const before = structuredClone(sources);
|
||||||
|
expect(confirmedOperationRanges([target], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
]);
|
||||||
|
expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
target,
|
||||||
|
]);
|
||||||
|
expect(sources).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('operations calendar windows', () => {
|
describe('operations calendar windows', () => {
|
||||||
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
||||||
|
|||||||
@@ -93,12 +93,12 @@ export function uncoveredOperationIntervals(
|
|||||||
return [fragment];
|
return [fragment];
|
||||||
}
|
}
|
||||||
return [
|
return [
|
||||||
...(fragment.from < covered.from ?
|
...(fragment.from < covered.from
|
||||||
[{ ...fragment, to: covered.from }] :
|
? [{ ...fragment, to: covered.from }]
|
||||||
[]),
|
: []),
|
||||||
...(covered.to < fragment.to ?
|
...(covered.to < fragment.to
|
||||||
[{ ...fragment, from: covered.to }] :
|
? [{ ...fragment, from: covered.to }]
|
||||||
[]),
|
: []),
|
||||||
];
|
];
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -106,6 +106,24 @@ export function uncoveredOperationIntervals(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmedOperationRanges(
|
||||||
|
targets: OperationInterval[],
|
||||||
|
coverage: OperationInterval[],
|
||||||
|
): Omit<OperationInterval, 'instrumentUid'>[] {
|
||||||
|
return targets.flatMap((target) => coverage
|
||||||
|
.filter((row) =>
|
||||||
|
row.accountId === target.accountId &&
|
||||||
|
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
|
||||||
|
row.from < target.to && target.from < row.to,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
accountId: target.accountId,
|
||||||
|
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
||||||
|
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type OperationCoverage = OperationInterval & {
|
export type OperationCoverage = OperationInterval & {
|
||||||
asOf: Date;
|
asOf: Date;
|
||||||
fetchedAt: Date;
|
fetchedAt: Date;
|
||||||
@@ -176,20 +194,20 @@ export function planOperationRead(
|
|||||||
// Completeness is bounded by the observed end, never by the new wall clock.
|
// Completeness is bounded by the observed end, never by the new wall clock.
|
||||||
return {
|
return {
|
||||||
...effective,
|
...effective,
|
||||||
availability: complete ?
|
availability: complete
|
||||||
('complete' as const) :
|
? ('complete' as const)
|
||||||
sources.length ?
|
: sources.length
|
||||||
('partial' as const) :
|
? ('partial' as const)
|
||||||
('missing' as const),
|
: ('missing' as const),
|
||||||
stale,
|
stale,
|
||||||
asOf: sources.length ?
|
asOf: sources.length
|
||||||
new Date(
|
? new Date(
|
||||||
Math.min(
|
Math.min(
|
||||||
effective.to.valueOf(),
|
effective.to.valueOf(),
|
||||||
Math.max(...sources.map((row) => row.asOf.valueOf())),
|
Math.max(...sources.map((row) => row.asOf.valueOf())),
|
||||||
),
|
),
|
||||||
) :
|
)
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
return { intervals, load: operationIntervals(load) };
|
return { intervals, load: operationIntervals(load) };
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { formatDecimal } from '../../../../common/decimal';
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
TbankMoney,
|
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||||
TbankOperationItem,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
OperationCategory,
|
OperationCategory,
|
||||||
@@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0;
|
|||||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||||
|
|
||||||
const types: Record<string, OperationCategory> = {
|
const types: Record<string, OperationCategory> = {
|
||||||
OPERATION_TYPE_BUY: 'trade',
|
[TbankOperationType.Buy]: 'trade',
|
||||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
[TbankOperationType.BuyCard]: 'trade',
|
||||||
OPERATION_TYPE_SELL: 'trade',
|
[TbankOperationType.Sell]: 'trade',
|
||||||
OPERATION_TYPE_SELL_CARD: 'trade',
|
[TbankOperationType.SellCard]: 'trade',
|
||||||
OPERATION_TYPE_BUY_MARGIN: 'trade',
|
[TbankOperationType.BuyMargin]: 'trade',
|
||||||
OPERATION_TYPE_SELL_MARGIN: 'trade',
|
[TbankOperationType.SellMargin]: 'trade',
|
||||||
OPERATION_TYPE_COUPON: 'income',
|
[TbankOperationType.Coupon]: 'income',
|
||||||
OPERATION_TYPE_DIVIDEND: 'income',
|
[TbankOperationType.Dividend]: 'income',
|
||||||
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
|
[TbankOperationType.DividendTransfer]: 'income',
|
||||||
OPERATION_TYPE_DIV_EXT: 'income',
|
[TbankOperationType.DivExt]: 'income',
|
||||||
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
|
[TbankOperationType.TransIisBs]: 'transfer',
|
||||||
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
|
[TbankOperationType.TransBsBs]: 'transfer',
|
||||||
OPERATION_TYPE_BOND_TAX: 'tax',
|
[TbankOperationType.BondTax]: 'tax',
|
||||||
OPERATION_TYPE_TAX: 'tax',
|
[TbankOperationType.Tax]: 'tax',
|
||||||
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
|
[TbankOperationType.DividendTax]: 'tax',
|
||||||
OPERATION_TYPE_SERVICE_FEE: 'fee',
|
[TbankOperationType.ServiceFee]: 'fee',
|
||||||
OPERATION_TYPE_MARGIN_FEE: 'fee',
|
[TbankOperationType.MarginFee]: 'fee',
|
||||||
OPERATION_TYPE_BROKER_FEE: 'fee',
|
[TbankOperationType.BrokerFee]: 'fee',
|
||||||
OPERATION_TYPE_SUCCESS_FEE: 'fee',
|
[TbankOperationType.SuccessFee]: 'fee',
|
||||||
OPERATION_TYPE_INPUT: 'transfer',
|
[TbankOperationType.Input]: 'transfer',
|
||||||
OPERATION_TYPE_OUTPUT: 'transfer',
|
[TbankOperationType.Output]: 'transfer',
|
||||||
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
|
[TbankOperationType.InputSecurities]: 'transfer',
|
||||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
|
[TbankOperationType.OutputSecurities]: 'transfer',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function operationCategory(type: string | undefined): OperationCategory {
|
export function operationCategory(type: string | undefined): OperationCategory {
|
||||||
@@ -51,7 +49,7 @@ export function mapBrokerageOperation(
|
|||||||
return {
|
return {
|
||||||
id: operation.id ?? operation.cursor ?? '',
|
id: operation.id ?? operation.cursor ?? '',
|
||||||
occurredAt: occurredAt.toISOString(),
|
occurredAt: occurredAt.toISOString(),
|
||||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
type: operation.type ?? TbankOperationType.Unspecified,
|
||||||
category: operationCategory(operation.type),
|
category: operationCategory(operation.type),
|
||||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||||
figi: text(operation.figi),
|
figi: text(operation.figi),
|
||||||
@@ -61,14 +59,14 @@ export function mapBrokerageOperation(
|
|||||||
logoName: null,
|
logoName: null,
|
||||||
payment: money(operation.payment),
|
payment: money(operation.payment),
|
||||||
quantity:
|
quantity:
|
||||||
(operationCategory(operation.type) === 'trade' ?
|
(operationCategory(operation.type) === 'trade'
|
||||||
operation.quantityDone :
|
? operation.quantityDone
|
||||||
operation.quantity) === undefined ?
|
: operation.quantity) === undefined
|
||||||
null :
|
? null
|
||||||
String(
|
: String(
|
||||||
operationCategory(operation.type) === 'trade' ?
|
operationCategory(operation.type) === 'trade'
|
||||||
operation.quantityDone :
|
? operation.quantityDone
|
||||||
operation.quantity,
|
: operation.quantity,
|
||||||
),
|
),
|
||||||
price: money(operation.price),
|
price: money(operation.price),
|
||||||
accruedInterest: money(operation.accruedInt),
|
accruedInterest: money(operation.accruedInt),
|
||||||
@@ -82,9 +80,9 @@ function text(value: string | undefined): null | string {
|
|||||||
|
|
||||||
function money(value: TbankMoney | undefined) {
|
function money(value: TbankMoney | undefined) {
|
||||||
const currency = value?.currency?.trim().toUpperCase();
|
const currency = value?.currency?.trim().toUpperCase();
|
||||||
return currency && value ?
|
return currency && value
|
||||||
{ currency, value: formatDecimal(decimal(value)) } :
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function operationTimestamp(
|
export function operationTimestamp(
|
||||||
|
|||||||
+103
-103
@@ -12,12 +12,15 @@ import type {
|
|||||||
TbankOperationsByCursorResponse,
|
TbankOperationsByCursorResponse,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
dateOnlyBounds,
|
dateOnlyBounds,
|
||||||
DateOnlyRange,
|
DateOnlyRange,
|
||||||
moscowDate,
|
moscowDate,
|
||||||
} from '../../investment-calendar';
|
} from '../../investment-calendar';
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
@@ -110,9 +113,9 @@ export class BrokerageOperationsRepository {
|
|||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||||
const openedAt =
|
const openedAt =
|
||||||
oldest && accounts.every((account) => account.openedOn) ?
|
oldest && accounts.every((account) => account.openedOn)
|
||||||
new Date(`${oldest}T00:00:00+03:00`) :
|
? new Date(`${oldest}T00:00:00+03:00`)
|
||||||
undefined;
|
: undefined;
|
||||||
const periods = input.periods?.(openedAt).map((period) => {
|
const periods = input.periods?.(openedAt).map((period) => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const bounds = dateOnlyBounds(period, now);
|
const bounds = dateOnlyBounds(period, now);
|
||||||
@@ -137,7 +140,7 @@ export class BrokerageOperationsRepository {
|
|||||||
)
|
)
|
||||||
.digest('base64url');
|
.digest('base64url');
|
||||||
if (input.cursor && input.cursor.binding !== binding) {
|
if (input.cursor && input.cursor.binding !== binding) {
|
||||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||||
}
|
}
|
||||||
const scope = {
|
const scope = {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -176,21 +179,21 @@ export class BrokerageOperationsRepository {
|
|||||||
const rows = await tx.tbankOperation.findMany({
|
const rows = await tx.tbankOperation.findMany({
|
||||||
where: {
|
where: {
|
||||||
...scope,
|
...scope,
|
||||||
...(periods ?
|
...(periods
|
||||||
{
|
? {
|
||||||
OR: periods.map((period) => ({
|
OR: periods.map((period) => ({
|
||||||
occurredAt: { gte: period.from, lt: period.to },
|
occurredAt: { gte: period.from, lt: period.to },
|
||||||
})),
|
})),
|
||||||
} :
|
}
|
||||||
{ occurredAt: { gte: input.from, lt: input.to } }),
|
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||||
...(input.instrumentUid ?
|
...(input.instrumentUid
|
||||||
{ instrumentUid: input.instrumentUid } :
|
? { instrumentUid: input.instrumentUid }
|
||||||
{}),
|
: {}),
|
||||||
...(input.categories.length ?
|
...(input.categories.length
|
||||||
{ category: { in: input.categories } } :
|
? { category: { in: input.categories } }
|
||||||
{}),
|
: {}),
|
||||||
...(after ?
|
...(after
|
||||||
{
|
? {
|
||||||
OR: [
|
OR: [
|
||||||
{ occurredAt: { lt: new Date(after.at) } },
|
{ occurredAt: { lt: new Date(after.at) } },
|
||||||
{
|
{
|
||||||
@@ -203,8 +206,8 @@ export class BrokerageOperationsRepository {
|
|||||||
brokerOperationId: { gt: after.id },
|
brokerOperationId: { gt: after.id },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ occurredAt: 'desc' },
|
{ occurredAt: 'desc' },
|
||||||
@@ -305,9 +308,9 @@ export class BrokerageOperationsRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
historyFrom: new Date(
|
historyFrom: new Date(
|
||||||
account.openedOn ?
|
account.openedOn
|
||||||
`${account.openedOn}T00:00:00Z` :
|
? `${account.openedOn}T00:00:00Z`
|
||||||
'1970-01-01T00:00:00Z',
|
: '1970-01-01T00:00:00Z',
|
||||||
),
|
),
|
||||||
historyBoundary: account.openedOn ? 'known' : 'unknown',
|
historyBoundary: account.openedOn ? 'known' : 'unknown',
|
||||||
},
|
},
|
||||||
@@ -383,58 +386,21 @@ export class BrokerageOperationsRepository {
|
|||||||
) {
|
) {
|
||||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||||
const additions =
|
const additions =
|
||||||
previous.scopeKey === scopeKey ?
|
previous.scopeKey === scopeKey
|
||||||
[] :
|
? []
|
||||||
uncoveredOperationIntervals(intervals, pending);
|
: uncoveredOperationIntervals(intervals, pending);
|
||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: previous.id },
|
||||||
data: {
|
data: {
|
||||||
intent: {
|
intent: extendRunningIntent({
|
||||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
previous: previousIntent,
|
||||||
atomicIntervals: previousIntent?.atomic ?
|
publicationAtomic,
|
||||||
[
|
pending,
|
||||||
...new Set([
|
additions,
|
||||||
...(previousIntent.atomicIntervals ??
|
intervals,
|
||||||
previousIntent.intervals),
|
priority,
|
||||||
...additions.map(intervalKey),
|
previousPriority: previous.priority,
|
||||||
]),
|
}),
|
||||||
] :
|
|
||||||
[],
|
|
||||||
screenIntervals: [
|
|
||||||
...new Set([
|
|
||||||
...(previousIntent?.screenIntervals ??
|
|
||||||
(previous.priority === operationsSyncPriority.screen ?
|
|
||||||
(previousIntent?.intervals ?? []) :
|
|
||||||
[])),
|
|
||||||
...(priority === operationsSyncPriority.screen ?
|
|
||||||
[...pending, ...additions]
|
|
||||||
.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map(intervalKey) :
|
|
||||||
[]),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
intervals: (priority < previous.priority ?
|
|
||||||
[
|
|
||||||
...pending.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...additions,
|
|
||||||
...pending.filter(
|
|
||||||
(item) =>
|
|
||||||
!intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
] :
|
|
||||||
[...pending, ...additions]
|
|
||||||
).map(intervalKey),
|
|
||||||
},
|
|
||||||
priority: Math.min(priority, previous.priority),
|
priority: Math.min(priority, previous.priority),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -444,9 +410,9 @@ export class BrokerageOperationsRepository {
|
|||||||
previous &&
|
previous &&
|
||||||
previous.state !== 'complete' &&
|
previous.state !== 'complete' &&
|
||||||
previous.state !== 'superseded';
|
previous.state !== 'superseded';
|
||||||
const retained = recovering ?
|
const retained = recovering
|
||||||
(previousIntent?.intervals.map(decodeInterval) ?? []) :
|
? (previousIntent?.intervals.map(decodeInterval) ?? [])
|
||||||
[];
|
: [];
|
||||||
// A bridge between two active groups must fence both producers before
|
// A bridge between two active groups must fence both producers before
|
||||||
// either can publish a subset of the newly combined result.
|
// either can publish a subset of the newly combined result.
|
||||||
for (const joined of touching) {
|
for (const joined of touching) {
|
||||||
@@ -469,23 +435,23 @@ export class BrokerageOperationsRepository {
|
|||||||
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
await tx.tbankSyncStage.deleteMany({ where: { workId: previous.id } });
|
||||||
}
|
}
|
||||||
const targets =
|
const targets =
|
||||||
recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP ?
|
recovering && !upgradeGroup && touching.length <= SINGLE_WORK_GROUP
|
||||||
previous?.scopeKey === scopeKey ?
|
? previous?.scopeKey === scopeKey
|
||||||
retained :
|
? retained
|
||||||
[...retained, ...uncoveredOperationIntervals(intervals, retained)] :
|
: [...retained, ...uncoveredOperationIntervals(intervals, retained)]
|
||||||
operationIntervals([...retained, ...intervals]);
|
: operationIntervals([...retained, ...intervals]);
|
||||||
const atomicTargets = [
|
const atomicTargets = [
|
||||||
...(publicationAtomic ? intervals : []),
|
...(publicationAtomic ? intervals : []),
|
||||||
...(recovering && previousIntent?.atomic ?
|
...(recovering && previousIntent?.atomic
|
||||||
(previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
? (previousIntent.atomicIntervals ?? previousIntent.intervals).map(
|
||||||
decodeInterval,
|
decodeInterval,
|
||||||
) :
|
)
|
||||||
[]),
|
: []),
|
||||||
...touching.flatMap((work) => {
|
...touching.flatMap((work) => {
|
||||||
const joined = work.intent as OperationsIntent;
|
const joined = work.intent as OperationsIntent;
|
||||||
return joined.atomic ?
|
return joined.atomic
|
||||||
(joined.atomicIntervals ?? joined.intervals).map(decodeInterval) :
|
? (joined.atomicIntervals ?? joined.intervals).map(decodeInterval)
|
||||||
[];
|
: [];
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
const intent = {
|
const intent = {
|
||||||
@@ -506,9 +472,9 @@ export class BrokerageOperationsRepository {
|
|||||||
)) ||
|
)) ||
|
||||||
(
|
(
|
||||||
previousIntent?.screenIntervals ??
|
previousIntent?.screenIntervals ??
|
||||||
(previous?.priority === operationsSyncPriority.screen ?
|
(previous?.priority === operationsSyncPriority.screen
|
||||||
(previousIntent?.intervals ?? []) :
|
? (previousIntent?.intervals ?? [])
|
||||||
[])
|
: [])
|
||||||
).some((key) =>
|
).some((key) =>
|
||||||
operationIntervalsOverlap(target, decodeInterval(key)),
|
operationIntervalsOverlap(target, decodeInterval(key)),
|
||||||
),
|
),
|
||||||
@@ -563,20 +529,20 @@ export class BrokerageOperationsRepository {
|
|||||||
marker.payload as PageProgress,
|
marker.payload as PageProgress,
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
const group = intent.atomic ?
|
const group = intent.atomic
|
||||||
(intent.atomicIntervals ?? intent.intervals) :
|
? (intent.atomicIntervals ?? intent.intervals)
|
||||||
intent.intervals;
|
: intent.intervals;
|
||||||
const nextKey = group.find((key) => !progress.get(key)?.complete);
|
const nextKey = group.find((key) => !progress.get(key)?.complete);
|
||||||
return {
|
return {
|
||||||
priority: work.priority,
|
priority: work.priority,
|
||||||
atomic: intent.atomic,
|
atomic: intent.atomic,
|
||||||
intervals: group.map(decodeInterval),
|
intervals: group.map(decodeInterval),
|
||||||
next: nextKey ?
|
next: nextKey
|
||||||
{
|
? {
|
||||||
interval: decodeInterval(nextKey),
|
interval: decodeInterval(nextKey),
|
||||||
cursor: progress.get(nextKey)?.cursor || undefined,
|
cursor: progress.get(nextKey)?.cursor || undefined,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +581,7 @@ export class BrokerageOperationsRepository {
|
|||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
data: {
|
data: {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (invalidToken) {
|
if (invalidToken) {
|
||||||
@@ -784,7 +750,7 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
const rows = staged.filter((row) => {
|
const rows = staged.filter((row) => {
|
||||||
const state = (row.payload as TbankOperationItem).state;
|
const state = (row.payload as TbankOperationItem).state;
|
||||||
return !state || state === 'OPERATION_STATE_EXECUTED';
|
return !state || isTbankProtocolValue(state, TbankOperationState.Executed);
|
||||||
});
|
});
|
||||||
const scope = {
|
const scope = {
|
||||||
userId: attempt.userId,
|
userId: attempt.userId,
|
||||||
@@ -795,9 +761,9 @@ export class BrokerageOperationsRepository {
|
|||||||
where: {
|
where: {
|
||||||
...scope,
|
...scope,
|
||||||
occurredAt: { gte: interval.from, lt: interval.to },
|
occurredAt: { gte: interval.from, lt: interval.to },
|
||||||
...(interval.instrumentUid ?
|
...(interval.instrumentUid
|
||||||
{ instrumentUid: interval.instrumentUid } :
|
? { instrumentUid: interval.instrumentUid }
|
||||||
{}),
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const incoming = new Set(rows.map((row) => row.itemKey));
|
const incoming = new Set(rows.map((row) => row.itemKey));
|
||||||
@@ -902,9 +868,9 @@ export class BrokerageOperationsRepository {
|
|||||||
intent.screenIntervals ??
|
intent.screenIntervals ??
|
||||||
(work.priority === operationsSyncPriority.screen ? intent.intervals : [])
|
(work.priority === operationsSyncPriority.screen ? intent.intervals : [])
|
||||||
).includes(key),
|
).includes(key),
|
||||||
) ?
|
)
|
||||||
operationsSyncPriority.screen :
|
? operationsSyncPriority.screen
|
||||||
operationsSyncPriority.history,
|
: operationsSyncPriority.history,
|
||||||
progress: { remainingIntervals: remaining.length },
|
progress: { remainingIntervals: remaining.length },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -916,6 +882,40 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extendRunningIntent(input: {
|
||||||
|
additions: OperationInterval[];
|
||||||
|
intervals: OperationInterval[];
|
||||||
|
pending: OperationInterval[];
|
||||||
|
previous: OperationsIntent | undefined;
|
||||||
|
previousPriority: number;
|
||||||
|
priority: number;
|
||||||
|
publicationAtomic: boolean;
|
||||||
|
}): OperationsIntent {
|
||||||
|
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||||
|
const overlapsRequested = (item: OperationInterval) =>
|
||||||
|
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||||
|
const retainedScreen = previous?.screenIntervals ??
|
||||||
|
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||||
|
const addedScreen = priority === operationsSyncPriority.screen
|
||||||
|
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||||
|
: [];
|
||||||
|
const ordered = priority < previousPriority
|
||||||
|
? [
|
||||||
|
...pending.filter(overlapsRequested),
|
||||||
|
...additions,
|
||||||
|
...pending.filter((item) => !overlapsRequested(item)),
|
||||||
|
]
|
||||||
|
: [...pending, ...additions];
|
||||||
|
return {
|
||||||
|
atomic: previous?.atomic ?? publicationAtomic,
|
||||||
|
atomicIntervals: previous?.atomic
|
||||||
|
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||||
|
: [],
|
||||||
|
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||||
|
intervals: ordered.map(intervalKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function intervalKey(interval: OperationInterval): string {
|
function intervalKey(interval: OperationInterval): string {
|
||||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||||
if (!(interval.from < interval.to)) {
|
if (!(interval.from < interval.to)) {
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
|
|
||||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
ForecastRepository,
|
ForecastRepository,
|
||||||
@@ -10,7 +9,6 @@ import {
|
|||||||
} from '../infrastructure/forecast.repository';
|
} from '../infrastructure/forecast.repository';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ForecastService {
|
export class ForecastService {
|
||||||
@@ -36,8 +34,8 @@ export class ForecastService {
|
|||||||
...input,
|
...input,
|
||||||
portfolioProjection: input.portfolio.dataRevision,
|
portfolioProjection: input.portfolio.dataRevision,
|
||||||
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
portfolios: (input.portfolio.dependencies ?? []).flatMap((dependency) =>
|
||||||
dependency.resourceKey.startsWith('portfolio/') ?
|
dependency.resourceKey.startsWith('portfolio/')
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
accountId: decodeURIComponent(
|
accountId: decodeURIComponent(
|
||||||
dependency.resourceKey.slice('portfolio/'.length),
|
dependency.resourceKey.slice('portfolio/'.length),
|
||||||
@@ -46,21 +44,21 @@ export class ForecastService {
|
|||||||
(dependency as InvestmentResourceMetaDto).dataRevision,
|
(dependency as InvestmentResourceMetaDto).dataRevision,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
),
|
),
|
||||||
sources: input.sources.flatMap((source) =>
|
sources: input.sources.flatMap((source) =>
|
||||||
source.version ? [source.version] : [],
|
source.version ? [source.version] : [],
|
||||||
),
|
),
|
||||||
timestamps: dependencies.flatMap((dependency) =>
|
timestamps: dependencies.flatMap((dependency) =>
|
||||||
dependency.lastSuccessfulAt ?
|
dependency.lastSuccessfulAt
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
resourceKey: dependency.resourceKey,
|
resourceKey: dependency.resourceKey,
|
||||||
at: dependency.lastSuccessfulAt,
|
at: dependency.lastSuccessfulAt,
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
const timestamps = stored?.sourceTimestamps as
|
const timestamps = stored?.sourceTimestamps as
|
||||||
@@ -73,38 +71,20 @@ export class ForecastService {
|
|||||||
dependencies
|
dependencies
|
||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
const payload = stored ? (stored.payload as T) : null;
|
|
||||||
const data =
|
|
||||||
payload &&
|
|
||||||
'positionIsStale' in payload &&
|
|
||||||
'positionAt' in payload &&
|
|
||||||
typeof payload.positionAt === 'string' ?
|
|
||||||
{
|
|
||||||
...payload,
|
|
||||||
positionIsStale: tbankSourceNeedsRefresh(
|
|
||||||
'portfolio',
|
|
||||||
new Date(payload.positionAt),
|
|
||||||
new Date(Date.now()),
|
|
||||||
),
|
|
||||||
} :
|
|
||||||
payload;
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: stored ? (stored.payload as T) : null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: input.scopeKey,
|
resourceKey: input.scopeKey,
|
||||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
availability: stored ?
|
availability: stored
|
||||||
'complete' :
|
? 'complete'
|
||||||
dependencies.some(
|
: dependencies.some(
|
||||||
(dependency) => dependency.availability !== 'missing',
|
(dependency) => dependency.availability !== 'missing',
|
||||||
) ?
|
)
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing',
|
: 'missing',
|
||||||
fetchedAt: first ? new Date(first) : null,
|
fetchedAt: first ? new Date(first) : null,
|
||||||
freshForMs:
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
data && 'positionIsStale' in data && data.positionIsStale === true ?
|
|
||||||
STALE_FRESHNESS_MS :
|
|
||||||
Number.POSITIVE_INFINITY,
|
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
|
||||||
import { setImmediate as immediate } from 'node:timers/promises';
|
import { setImmediate as immediate } from 'node:timers/promises';
|
||||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
@@ -23,12 +22,10 @@ describe('persistent portfolio projection', () => {
|
|||||||
service = new PortfolioService(
|
service = new PortfolioService(
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
{ set: jest.fn() } as never,
|
|
||||||
metadata as never,
|
metadata as never,
|
||||||
repository as never,
|
repository as never,
|
||||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||||
{ event: jest.fn() } as never,
|
{ event: jest.fn() } as never,
|
||||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
|
||||||
);
|
);
|
||||||
access.local.mockResolvedValue({
|
access.local.mockResolvedValue({
|
||||||
revision: 'private',
|
revision: 'private',
|
||||||
@@ -129,6 +126,43 @@ describe('persistent portfolio projection', () => {
|
|||||||
]);
|
]);
|
||||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||||
});
|
});
|
||||||
|
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
|
||||||
|
{
|
||||||
|
quantity: { units: '1' },
|
||||||
|
currentPrice: { currency: 'USD', units: '3' },
|
||||||
|
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||||
|
},
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [
|
||||||
|
{ currency: 'rub', units: '2' },
|
||||||
|
{ currency: 'RUB', units: '-2' },
|
||||||
|
{ units: '99' },
|
||||||
|
{ currency: 'EUR', units: '1' },
|
||||||
|
] });
|
||||||
|
await service.request('user', 'private', 'a');
|
||||||
|
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
|
||||||
|
await immediate();
|
||||||
|
}
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.cashBalances).toEqual([
|
||||||
|
{ currency: 'RUB', value: '0' },
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
{ currency: 'USD', value: '3' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.positions[0].currentValue).toBeNull();
|
||||||
|
expect(snapshot.positions[1].absoluteReturn).toBeNull();
|
||||||
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
@@ -184,15 +218,4 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(complete.data?.isStale).toBe(true);
|
expect(complete.data?.isStale).toBe(true);
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
|
||||||
repository.read.mockResolvedValue({
|
|
||||||
membershipAt: null,
|
|
||||||
membershipRevision: 0,
|
|
||||||
accounts: [],
|
|
||||||
work: [],
|
|
||||||
});
|
|
||||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,34 +1,22 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
|
||||||
isZero,
|
|
||||||
multiply,
|
|
||||||
subtract,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
type MetadataIdentity,
|
type MetadataIdentity,
|
||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankInstrument,
|
|
||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
TbankMoney,
|
type TbankMoney,
|
||||||
|
type TbankPortfolio,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { CandleService } from '../../instruments/instruments.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||||
@@ -37,46 +25,29 @@ import {
|
|||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
import {
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
PortfolioDto,
|
|
||||||
PortfolioPositionDto,
|
|
||||||
} from '../presentation/portfolio.dto';
|
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
|
||||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
const types: Record<string, string> = {
|
|
||||||
share: 'STOCK',
|
|
||||||
bond: 'BOND',
|
|
||||||
etf: 'FUND',
|
|
||||||
currency: 'CURRENCY',
|
|
||||||
futures: 'FUTURE',
|
|
||||||
option: 'OPTION',
|
|
||||||
sp: 'STRUCTURED_NOTE',
|
|
||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
private readonly processRunId = randomUUID();
|
private readonly processRunId = randomUUID();
|
||||||
private readonly active = new Set<string>();
|
|
||||||
private readonly pending = new Map<string, Promise<void>>();
|
private readonly pending = new Map<string, Promise<void>>();
|
||||||
private reservations: Promise<unknown> = Promise.resolve();
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: PortfolioRepository,
|
private readonly repository: PortfolioRepository,
|
||||||
private readonly membership: TbankMembershipService,
|
private readonly membership: TbankMembershipService,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
private readonly candles: CandleService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async saved(userId: string, accountId: string, observe = false) {
|
async saved(userId: string, accountId: string, observe = false) {
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
if (!observe) {
|
if (!observe) {
|
||||||
const discovery = await this.membership.request(
|
const discovery = await this.membership.request(
|
||||||
@@ -145,28 +116,6 @@ export class PortfolioService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!observe) {
|
|
||||||
void this.candles
|
|
||||||
.prefetch(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
(saved?.accounts ?? [])
|
|
||||||
.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
)
|
|
||||||
.flatMap(
|
|
||||||
(account) =>
|
|
||||||
(
|
|
||||||
account.portfolio?.payload as unknown as
|
|
||||||
PortfolioDto | undefined
|
|
||||||
)?.positions ?? [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() =>
|
|
||||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const membershipMeta = investmentResourceMeta({
|
const membershipMeta = investmentResourceMeta({
|
||||||
resourceKey: 'accounts/membership',
|
resourceKey: 'accounts/membership',
|
||||||
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -186,15 +135,15 @@ export class PortfolioService {
|
|||||||
name: account.name,
|
name: account.name,
|
||||||
type: account.type,
|
type: account.type,
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
snapshot: account.portfolio ?
|
snapshot: account.portfolio
|
||||||
{
|
? {
|
||||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
...(account.portfolio.payload as unknown as PortfolioDto),
|
||||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
||||||
isStale:
|
isStale:
|
||||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
||||||
portfolioFreshForMs,
|
portfolioFreshForMs,
|
||||||
} :
|
}
|
||||||
null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||||
dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(account.portfolio?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -220,8 +169,8 @@ export class PortfolioService {
|
|||||||
);
|
);
|
||||||
const metadataDependencies = [...metadata.entries()].flatMap(
|
const metadataDependencies = [...metadata.entries()].flatMap(
|
||||||
([key, source]) =>
|
([key, source]) =>
|
||||||
source.complete && source.data ?
|
source.complete && source.data
|
||||||
[
|
? [
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
resourceKey: `instrument/metadata/${key}`,
|
resourceKey: `instrument/metadata/${key}`,
|
||||||
dataRevision: source.dataRevision,
|
dataRevision: source.dataRevision,
|
||||||
@@ -230,20 +179,20 @@ export class PortfolioService {
|
|||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.metadata,
|
||||||
work: null,
|
work: null,
|
||||||
}),
|
}),
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
const portfolios = saved.accounts.flatMap((account) =>
|
const portfolios = saved.accounts.flatMap((account) =>
|
||||||
account.snapshot ?
|
account.snapshot
|
||||||
[
|
? [
|
||||||
{
|
{
|
||||||
...account.snapshot,
|
...account.snapshot,
|
||||||
positions: account.snapshot.positions.map((position) => {
|
positions: account.snapshot.positions.map((position) => {
|
||||||
const shared = metadata.get(metadataKey(position))?.data;
|
const shared = metadata.get(metadataKey(position))?.data;
|
||||||
return {
|
return {
|
||||||
...position,
|
...position,
|
||||||
...(shared ?
|
...(shared
|
||||||
{
|
? {
|
||||||
displayName:
|
displayName:
|
||||||
shared.name?.trim() ||
|
shared.name?.trim() ||
|
||||||
shared.ticker?.trim() ||
|
shared.ticker?.trim() ||
|
||||||
@@ -252,29 +201,29 @@ export class PortfolioService {
|
|||||||
isin: shared.isin?.trim() || position.isin,
|
isin: shared.isin?.trim() || position.isin,
|
||||||
logoName:
|
logoName:
|
||||||
shared.brand?.logoName?.trim() || position.logoName,
|
shared.brand?.logoName?.trim() || position.logoName,
|
||||||
} :
|
}
|
||||||
{}),
|
: {}),
|
||||||
...(accountId === 'all' ?
|
...(accountId === 'all'
|
||||||
{ account: { id: account.id, name: account.name } } :
|
? { account: { id: account.id, name: account.name } }
|
||||||
{}),
|
: {}),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
] :
|
]
|
||||||
[],
|
: [],
|
||||||
);
|
);
|
||||||
const lastSuccessfulAt =
|
const lastSuccessfulAt =
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||||
saved.membership.lastSuccessfulAt;
|
saved.membership.lastSuccessfulAt;
|
||||||
const data =
|
const data =
|
||||||
missing || !lastSuccessfulAt ?
|
missing || !lastSuccessfulAt
|
||||||
null :
|
? null
|
||||||
{
|
: {
|
||||||
lastSuccessfulAt,
|
lastSuccessfulAt,
|
||||||
isStale: portfolios.length ?
|
isStale: portfolios.length
|
||||||
portfolios.some((portfolio) => portfolio.isStale) :
|
? portfolios.some((portfolio) => portfolio.isStale)
|
||||||
saved.membership.freshness !== 'fresh',
|
: saved.membership.freshness !== 'fresh',
|
||||||
totalsByCurrency: sumMoneyByCurrency(
|
totalsByCurrency: sumMoneyByCurrency(
|
||||||
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
portfolios.flatMap((portfolio) => portfolio.totalsByCurrency),
|
||||||
),
|
),
|
||||||
@@ -306,15 +255,15 @@ export class PortfolioService {
|
|||||||
]),
|
]),
|
||||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||||
]),
|
]),
|
||||||
availability: missing ?
|
availability: missing
|
||||||
portfolios.length ?
|
? portfolios.length
|
||||||
'partial' :
|
? 'partial'
|
||||||
'missing' :
|
: 'missing'
|
||||||
'complete',
|
: 'complete',
|
||||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||||
freshForMs: portfolios.length ?
|
freshForMs: portfolios.length
|
||||||
portfolioFreshForMs :
|
? portfolioFreshForMs
|
||||||
membershipFreshForMs,
|
: membershipFreshForMs,
|
||||||
work: work.id ? { ...work, id: work.id } : null,
|
work: work.id ? { ...work, id: work.id } : null,
|
||||||
dependencies: [
|
dependencies: [
|
||||||
saved.membership,
|
saved.membership,
|
||||||
@@ -341,7 +290,7 @@ export class PortfolioService {
|
|||||||
revision,
|
revision,
|
||||||
accountId,
|
accountId,
|
||||||
this.processRunId,
|
this.processRunId,
|
||||||
this.active,
|
this.pending,
|
||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if (!requested) return null;
|
if (!requested) return null;
|
||||||
@@ -352,7 +301,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { id, attemptId } = requested.work;
|
const { id, attemptId } = requested.work;
|
||||||
this.active.add(attemptId);
|
|
||||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const attempt = { id, attemptId, userId, revision };
|
const attempt = { id, attemptId, userId, revision };
|
||||||
@@ -376,18 +324,11 @@ export class PortfolioService {
|
|||||||
for (const position of snapshot.positions) {
|
for (const position of snapshot.positions) {
|
||||||
await this.metadata.request(userId, revision, position);
|
await this.metadata.request(userId, revision, position);
|
||||||
}
|
}
|
||||||
if (!force) {
|
|
||||||
await this.candles.prefetch(
|
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
snapshot.positions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN'
|
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)
|
||||||
) {
|
) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else await this.repository.fail(attempt);
|
} else await this.repository.fail(attempt);
|
||||||
@@ -399,7 +340,6 @@ export class PortfolioService {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.active.delete(attemptId);
|
|
||||||
this.pending.delete(attemptId);
|
this.pending.delete(attemptId);
|
||||||
});
|
});
|
||||||
this.pending.set(attemptId, done);
|
this.pending.set(attemptId, done);
|
||||||
@@ -409,122 +349,12 @@ export class PortfolioService {
|
|||||||
return reserved;
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
|
||||||
const result = await this.read(userId, accountId);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(userId: string): Promise<PortfolioDto> {
|
|
||||||
return this.get(userId, 'all');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: {
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
averagePositionPrice?: TbankMoney;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
figi?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
quantity?: { nano?: number | string; units?: number | string };
|
|
||||||
}[],
|
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
): Promise<PortfolioDto> {
|
): Promise<PortfolioDto> {
|
||||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||||
const positions = upstream.map((position) =>
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
||||||
this.position(
|
|
||||||
position,
|
|
||||||
savedMetadata.get(metadataKey(position))?.data ?? null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
|
||||||
for (const value of money) {
|
|
||||||
const currency = value.currency?.trim().toUpperCase();
|
|
||||||
const amount = decimal(value);
|
|
||||||
if (!currency || isZero(amount)) continue;
|
|
||||||
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
|
|
||||||
}
|
|
||||||
const cashBalances = [...cash].map(([currency, value]) => ({
|
|
||||||
currency,
|
|
||||||
value: formatDecimal(value),
|
|
||||||
}));
|
|
||||||
const normalizedPositions = positions.filter(
|
|
||||||
(position): position is PortfolioPositionDto => position !== null,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
lastSuccessfulAt,
|
|
||||||
isStale: false,
|
|
||||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
|
||||||
positions: normalizedPositions,
|
|
||||||
cashBalances,
|
|
||||||
structure: portfolioStructure(normalizedPositions, cashBalances),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private position(
|
|
||||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
|
||||||
metadata: null | TbankInstrument,
|
|
||||||
): null | PortfolioPositionDto {
|
|
||||||
const quantity = decimal(position.quantity ?? {});
|
|
||||||
if (isZero(quantity)) return null;
|
|
||||||
const figi = position.figi?.trim() || null;
|
|
||||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
|
||||||
const currentPrice = this.money(position.currentPrice);
|
|
||||||
const averagePurchasePrice = this.money(position.averagePositionPrice);
|
|
||||||
const currentValue = currentPrice && this.value(quantity, currentPrice);
|
|
||||||
const absoluteReturn =
|
|
||||||
currentPrice &&
|
|
||||||
averagePurchasePrice &&
|
|
||||||
currentPrice.currency === averagePurchasePrice.currency ?
|
|
||||||
{
|
|
||||||
currency: currentPrice.currency,
|
|
||||||
value: formatDecimal(
|
|
||||||
multiply(
|
|
||||||
subtract(
|
|
||||||
decimalFromString(currentPrice.value),
|
|
||||||
decimalFromString(averagePurchasePrice.value),
|
|
||||||
),
|
|
||||||
quantity,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
} :
|
|
||||||
null;
|
|
||||||
return {
|
|
||||||
figi,
|
|
||||||
instrumentUid,
|
|
||||||
displayName:
|
|
||||||
metadata?.name?.trim() ||
|
|
||||||
metadata?.ticker?.trim() ||
|
|
||||||
figi ||
|
|
||||||
'Unknown instrument',
|
|
||||||
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
|
||||||
quantity: formatDecimal(quantity),
|
|
||||||
currentValue,
|
|
||||||
averagePurchasePrice,
|
|
||||||
absoluteReturn,
|
|
||||||
isin: metadata?.isin?.trim() || null,
|
|
||||||
ticker: metadata?.ticker?.trim() || null,
|
|
||||||
logoName: metadata?.brand?.logoName?.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private value(
|
|
||||||
quantity: ReturnType<typeof decimal>,
|
|
||||||
price: MoneyDto,
|
|
||||||
): MoneyDto {
|
|
||||||
return {
|
|
||||||
currency: price.currency,
|
|
||||||
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
|
||||||
const currency = value?.currency?.trim().toUpperCase();
|
|
||||||
return currency && value ?
|
|
||||||
{ currency, value: formatDecimal(decimal(value)) } :
|
|
||||||
null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,9 +43,9 @@ export function portfolioStructure(
|
|||||||
currencies.set(money.currency, group);
|
currencies.set(money.currency, group);
|
||||||
};
|
};
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
const type = ['BOND', 'STOCK'].includes(position.type) ?
|
const type = ['BOND', 'STOCK'].includes(position.type)
|
||||||
position.type :
|
? position.type
|
||||||
'OTHER';
|
: 'OTHER';
|
||||||
if (position.currentValue) include(type, position.currentValue);
|
if (position.currentValue) include(type, position.currentValue);
|
||||||
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
|
else unvalued.set(type, (unvalued.get(type) ?? INITIAL_UNVALUED_COUNT) + POSITION_INCREMENT);
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,8 @@ export function portfolioStructure(
|
|||||||
value: formatDecimal(value),
|
value: formatDecimal(value),
|
||||||
// Rounded to six decimal places using integer arithmetic. Only the
|
// Rounded to six decimal places using integer arithmetic. Only the
|
||||||
// resulting dimensionless percentage is passed to chart geometry.
|
// resulting dimensionless percentage is passed to chart geometry.
|
||||||
sharePercent: canCalculateShares ?
|
sharePercent: canCalculateShares
|
||||||
formatDecimal({
|
? formatDecimal({
|
||||||
value:
|
value:
|
||||||
(value.value *
|
(value.value *
|
||||||
DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
|
DECIMAL_RADIX ** BigInt(total.scale - value.scale) *
|
||||||
@@ -74,8 +74,8 @@ export function portfolioStructure(
|
|||||||
total.value / ROUNDING_DIVISOR) /
|
total.value / ROUNDING_DIVISOR) /
|
||||||
total.value,
|
total.value,
|
||||||
scale: SHARE_PERCENT_DECIMAL_PLACES,
|
scale: SHARE_PERCENT_DECIMAL_PLACES,
|
||||||
}) :
|
})
|
||||||
null,
|
: null,
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
currency,
|
currency,
|
||||||
|
|||||||
@@ -44,23 +44,17 @@ export class ForecastRepository {
|
|||||||
data: { membershipRevision: { increment: 0 } },
|
data: { membershipRevision: { increment: 0 } },
|
||||||
});
|
});
|
||||||
if (!current.count) return null;
|
if (!current.count) return null;
|
||||||
}
|
} else if (!(await tx.tbankConnection.findFirst({
|
||||||
const connection = await tx.tbankConnection.findFirst({
|
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
|
||||||
where: {
|
}))) return null;
|
||||||
...owner,
|
|
||||||
membershipRevision: input.membershipRevision,
|
|
||||||
invalidToken: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!connection) return null;
|
|
||||||
const key = { ...owner, scopeKey: input.scopeKey };
|
const key = { ...owner, scopeKey: input.scopeKey };
|
||||||
const previous = await tx.tbankForecast.findUnique({
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
});
|
});
|
||||||
const fallback =
|
const fallback =
|
||||||
previous?.membershipRevision === input.membershipRevision ?
|
previous?.membershipRevision === input.membershipRevision
|
||||||
previous :
|
? previous
|
||||||
null;
|
: null;
|
||||||
if (!input.candidate) return fallback;
|
if (!input.candidate) return fallback;
|
||||||
const portfolios = await tx.tbankPortfolio.findMany({
|
const portfolios = await tx.tbankPortfolio.findMany({
|
||||||
where: {
|
where: {
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
tbankSourceNeedsRefresh,
|
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
||||||
tbankSourceTtlMs,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||||
@@ -54,7 +52,7 @@ export class PortfolioRepository {
|
|||||||
revision: string,
|
revision: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
processRunId: string,
|
processRunId: string,
|
||||||
active: ReadonlySet<string>,
|
pending: ReadonlyMap<string, Promise<void>>,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
@@ -85,7 +83,7 @@ export class PortfolioRepository {
|
|||||||
if (
|
if (
|
||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === processRunId &&
|
previous.processRunId === processRunId &&
|
||||||
active.has(previous.attemptId)
|
pending.has(previous.attemptId)
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
@@ -152,7 +150,7 @@ export class PortfolioRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: false },
|
connection: { invalidToken: false },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_ACCOUNTS;
|
return result.count === EXPECTED_UPDATED_ACCOUNTS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,110 @@
|
|||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
|
subtract,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
|
metadataKey,
|
||||||
|
type TbankInstrument,
|
||||||
|
type TbankMoney,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
TbankPositions,
|
TbankPositions,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
|
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
share: 'STOCK',
|
||||||
|
bond: 'BOND',
|
||||||
|
etf: 'FUND',
|
||||||
|
currency: 'CURRENCY',
|
||||||
|
futures: 'FUTURE',
|
||||||
|
option: 'OPTION',
|
||||||
|
sp: 'STRUCTURED_NOTE',
|
||||||
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPortfolio(
|
||||||
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
|
cash: TbankMoney[],
|
||||||
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
||||||
|
lastSuccessfulAt: string,
|
||||||
|
): PortfolioDto {
|
||||||
|
const positions = upstream
|
||||||
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
|
||||||
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
||||||
|
const balances = new Map<string, ReturnType<typeof decimal>>();
|
||||||
|
for (const value of cash) {
|
||||||
|
const currency = value.currency?.trim().toUpperCase();
|
||||||
|
const amount = decimal(value);
|
||||||
|
if (!currency || isZero(amount)) continue;
|
||||||
|
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
||||||
|
}
|
||||||
|
const cashBalances = [...balances].map(([currency, value]) => ({
|
||||||
|
currency,
|
||||||
|
value: formatDecimal(value),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
lastSuccessfulAt,
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: totalsByCurrency(upstream, cash),
|
||||||
|
positions,
|
||||||
|
cashBalances,
|
||||||
|
structure: portfolioStructure(positions, cashBalances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPosition(
|
||||||
|
position: NonNullable<TbankPortfolio['positions']>[number],
|
||||||
|
metadata: null | TbankInstrument,
|
||||||
|
): null | PortfolioPositionDto {
|
||||||
|
const quantity = decimal(position.quantity ?? {});
|
||||||
|
if (isZero(quantity)) return null;
|
||||||
|
const figi = position.figi?.trim() || null;
|
||||||
|
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||||
|
const currentPrice = money(position.currentPrice);
|
||||||
|
const averagePurchasePrice = money(position.averagePositionPrice);
|
||||||
|
const currentValue = currentPrice && {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
||||||
|
};
|
||||||
|
const absoluteReturn =
|
||||||
|
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
||||||
|
? {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(
|
||||||
|
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
||||||
|
quantity,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
figi,
|
||||||
|
instrumentUid,
|
||||||
|
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
|
||||||
|
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
||||||
|
quantity: formatDecimal(quantity),
|
||||||
|
currentValue,
|
||||||
|
averagePurchasePrice,
|
||||||
|
absoluteReturn,
|
||||||
|
isin: metadata?.isin?.trim() || null,
|
||||||
|
ticker: metadata?.ticker?.trim() || null,
|
||||||
|
logoName: metadata?.brand?.logoName?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: TbankMoney | undefined): Money | null {
|
||||||
|
const currency = value?.currency?.trim().toUpperCase();
|
||||||
|
return currency && value
|
||||||
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function totalsByCurrency(
|
function totalsByCurrency(
|
||||||
positions: NonNullable<TbankPortfolio['positions']>,
|
positions: NonNullable<TbankPortfolio['positions']>,
|
||||||
@@ -44,4 +138,4 @@ function totalsByCurrency(
|
|||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { totalsByCurrency };
|
export { mapPortfolio, totalsByCurrency };
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
|
|
||||||
import { ForecastService } from './application/forecast.service';
|
import { ForecastService } from './application/forecast.service';
|
||||||
import { PortfolioService } from './application/portfolio.service';
|
import { PortfolioService } from './application/portfolio.service';
|
||||||
import { ForecastRepository } from './infrastructure/forecast.repository';
|
import { ForecastRepository } from './infrastructure/forecast.repository';
|
||||||
@@ -10,7 +9,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
|
|||||||
import { PortfolioController } from './presentation/portfolio.controller';
|
import { PortfolioController } from './presentation/portfolio.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, InstrumentSourcesModule],
|
imports: [AuthModule, TbankModule],
|
||||||
controllers: [PortfolioController],
|
controllers: [PortfolioController],
|
||||||
providers: [
|
providers: [
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import {
|
import {
|
||||||
supportedTbankAccounts,
|
supportedTbankAccounts,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -29,7 +30,7 @@ export class TbankConnectionService {
|
|||||||
|
|
||||||
async put(userId: string, value: string) {
|
async put(userId: string, value: string) {
|
||||||
const token = value.trim();
|
const token = value.trim();
|
||||||
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
|
if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError });
|
||||||
const accounts = await this.tokens.translateInvalidToken(() =>
|
const accounts = await this.tokens.translateInvalidToken(() =>
|
||||||
this.tbank.validateToken(token),
|
this.tbank.validateToken(token),
|
||||||
);
|
);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user