Compare commits
85
Commits
9cc9fbdcff
...
v2.3.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a0e70b273 | ||
|
|
449cfcd6c5 | ||
|
|
966a1f793a | ||
|
|
d1f6969dc7 | ||
|
|
b20080ac85 | ||
|
|
32622c1dad | ||
|
|
4f485ab0e9 | ||
|
|
8a6f60d965 | ||
|
|
e03fad622b | ||
|
|
774e58ef80 | ||
|
|
702302126b | ||
|
|
ec6d9a2639 | ||
|
|
7bc7d8ddec | ||
|
|
9e4f9d9cab | ||
|
|
a93f2d55e7 | ||
|
|
96d4de4195 | ||
|
|
c789e33c41 | ||
|
|
2ad3517d87 | ||
|
|
0d7839cf79 | ||
|
|
6975853656 | ||
|
|
14bcb9da5f | ||
|
|
b90cedd580 | ||
|
|
da2e1d0781 | ||
|
|
bbb5afff84 | ||
|
|
285dd08472 | ||
|
|
1033d2114e | ||
|
|
203cc72cb2 | ||
|
|
9b7e5e1215 | ||
|
|
31b0e15579 | ||
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba | ||
|
|
b9b76bab91 | ||
|
|
fa57a201fd | ||
|
|
96a442253e | ||
|
|
c5248dbefc | ||
|
|
c7d060023b | ||
|
|
a31e8d2c4d | ||
|
|
2f7c15205f | ||
|
|
d7895f287e | ||
|
|
bcd5cb3dd0 | ||
|
|
0ad84ff4f0 | ||
|
|
7d10ef8adf | ||
|
|
bc631e72de | ||
|
|
947ffefa9b | ||
|
|
db375cbee7 | ||
|
|
a46a4112cb | ||
|
|
966b2a5836 | ||
|
|
323b83b582 | ||
|
|
5d857de7a5 | ||
|
|
2facd8675c | ||
|
|
4870d49503 | ||
|
|
85858a160f | ||
|
|
0057925888 | ||
|
|
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`
|
||||
для публичных consumers.
|
||||
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
||||
request context и ограничивает поля событий. Jest unit tests, изолированные
|
||||
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
|
||||
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
|
||||
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||
|
||||
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
|
||||
|
||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||
модулями providers, функции и типы. Внешний production import идёт через этот
|
||||
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и
|
||||
API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
|
||||
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
|
||||
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
||||
не загружает DI/Prisma.
|
||||
публичные входы для protobuf conversion, protocol/error vocabulary и interval
|
||||
rules: импорт этих значений и расчётов не загружает DI/Prisma.
|
||||
Внутри модуля используй прямые локальные импорты.
|
||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||
Новый export требует текущего потребителя; controller и persistence остаются
|
||||
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
||||
Исключения для индексов, default values, enum/type declarations и readonly
|
||||
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
||||
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
||||
Machine codes и protocol values задаются строковыми enum у владельца.
|
||||
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
|
||||
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
|
||||
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
|
||||
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
|
||||
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
|
||||
|
||||
## Logging
|
||||
|
||||
@@ -261,5 +267,8 @@ semantic magic strings проверяются conventions/review без ново
|
||||
## Проверка
|
||||
|
||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
||||
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||
controllers делегируют application, use cases не импортируют Prisma. Чистые
|
||||
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
|
||||
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
|
||||
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||
на согласование до зависимой работы.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
|
||||
начинай после явного решения Change Owner.
|
||||
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
|
||||
не используй системный Node.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
@@ -29,7 +37,8 @@ owner и 1–3 примера. Код показывает механизм; с
|
||||
## Domain values
|
||||
|
||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
||||
Переиспользуй его symbols; для machine codes и protocol values используй
|
||||
строковые `enum`, для структуры с разными payload — discriminated union.
|
||||
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||
обычные labels не выноси в constants без semantic benefit.
|
||||
@@ -56,19 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
| Когда | Что проверить |
|
||||
| --- | --- |
|
||||
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||
|
||||
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||
проверку при изменении значимых входов (включая config, dependencies и
|
||||
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
|
||||
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
||||
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
|
||||
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
|
||||
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||
`origin/main` всегда предок `origin/develop`:
|
||||
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||
Перед релизом прочитай местный README, раздел «Релизы».
|
||||
|
||||
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
|
||||
|
||||
Преобразование provider records происходит на boundary: например,
|
||||
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
||||
остаётся отдельным чистым входом integration. Domain получает внутренние
|
||||
остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
|
||||
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
|
||||
не должны загружать DI/Prisma через service exports. Domain получает внутренние
|
||||
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
||||
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
||||
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
||||
@@ -41,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
|
||||
[validated environment](../../src/config/env.validation.ts) и
|
||||
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
||||
|
||||
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
|
||||
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
|
||||
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
|
||||
|
||||
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
||||
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
||||
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||
- RequestContextService и request middleware владеют correlation: серверный
|
||||
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
||||
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
|
||||
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
|
||||
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
|
||||
или HTTP completion middleware не нужен.
|
||||
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||
|
||||
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
|
||||
проверки browser/HTTP поведения, не только TypeScript.
|
||||
|
||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
||||
401, но не меняет cookie и сам по себе не отзывает session победителя.
|
||||
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
|
||||
Не вводи Web Locks, replay history/grace
|
||||
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||
сессию и очищает refresh cookie на HTTP-границе.
|
||||
|
||||
|
||||
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||
|
||||
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
||||
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
|
||||
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
|
||||
его абсолютный wall-clock budget не является merge gate на shared runner.
|
||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Неверное поведение
|
||||
---
|
||||
## Проблема и шаги воспроизведения
|
||||
|
||||
## Факт / ожидание
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Окружение / ссылки (если применимо)
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Engineering Task
|
||||
about: CI, тесты, документация, процесс и прочее
|
||||
---
|
||||
## Контекст и результат
|
||||
|
||||
## Scope / связанные Issues (если есть)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Новое наблюдаемое поведение
|
||||
---
|
||||
## Зачем и какой результат
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / связанные Issues (если есть)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Refactoring / Technical Debt
|
||||
about: Улучшение без нового поведения
|
||||
---
|
||||
## Проблема и результат
|
||||
|
||||
## Сохраняемые инварианты / границы
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / ссылки (если есть)
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
## Linked Issue
|
||||
Refs
|
||||
|
||||
## Changes
|
||||
|
||||
## Verification
|
||||
- Команда / результат / ограничения:
|
||||
|
||||
## Breaking Changes (если есть)
|
||||
@@ -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"
|
||||
@@ -28,9 +28,8 @@ jobs:
|
||||
docker version
|
||||
docker buildx version
|
||||
- run: npm ci
|
||||
- run: npx --no-install prisma generate
|
||||
- run: npm run verify
|
||||
- run: npm run test:e2e
|
||||
- run: node test/sqlite.cjs --all
|
||||
- name: Login with dedicated Nexus writer
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||
|
||||
@@ -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,80 @@ name: Backend verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ['**']
|
||||
branches: [develop, main]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, edited]
|
||||
schedule:
|
||||
- cron: '47 5 * * *'
|
||||
|
||||
# Gitea >= 1.26. A new revision supersedes only the same PR/branch/event.
|
||||
concurrency:
|
||||
group: backend-verify-${{ gitea.event_name }}-${{ gitea.event.pull_request.number || gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
commits:
|
||||
if: gitea.event_name == 'pull_request'
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha }}
|
||||
fetch-depth: 0
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci --ignore-scripts
|
||||
- name: Check PR title and commits
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||
else
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
fi
|
||||
|
||||
security:
|
||||
needs: commits
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
gitea.event.action != 'edited' &&
|
||||
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- name: Record checked revision
|
||||
run: git rev-parse HEAD
|
||||
- name: Scan history with Gitleaks 8.30.1
|
||||
run: |
|
||||
curl --fail --location --silent --show-error https://github.com/gitleaks/gitleaks/releases/download/v8.30.1/gitleaks_8.30.1_linux_x64.tar.gz -o /tmp/gitleaks.tar.gz
|
||||
echo '551f6fc83ea457d62a0d98237cbad105af8d557003051f41f3e7ca7b3f2470eb /tmp/gitleaks.tar.gz' | sha256sum --check
|
||||
mkdir -p /tmp/gitleaks-bin
|
||||
tar -xzf /tmp/gitleaks.tar.gz -C /tmp/gitleaks-bin gitleaks
|
||||
/tmp/gitleaks-bin/gitleaks git --log-opts=--all --redact=100 --no-banner
|
||||
- uses: https://github.com/actions/setup-node@v4
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Audit runtime and development dependencies
|
||||
run: |
|
||||
npm audit --omit=dev --audit-level=low
|
||||
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
|
||||
|
||||
verify:
|
||||
needs: [commits, security]
|
||||
if: >-
|
||||
!cancelled() && gitea.event.action != 'edited' &&
|
||||
needs.security.result == 'success' &&
|
||||
gitea.event_name != 'schedule'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: https://github.com/actions/checkout@v4
|
||||
@@ -18,12 +87,10 @@ jobs:
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Generate Prisma client before typechecks
|
||||
run: npx --no-install prisma generate
|
||||
- name: Run all backend gates
|
||||
run: npm run verify
|
||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||
run: npm run test:e2e
|
||||
run: node test/sqlite.cjs --all
|
||||
- name: Analyze develop with SonarQube
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||
(main#28; после merge — актуальная версия `develop`).
|
||||
|
||||
| Решение / сигнал | Читать |
|
||||
| --- | --- |
|
||||
|
||||
+169
@@ -1,5 +1,174 @@
|
||||
# Changelog
|
||||
|
||||
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
|
||||
|
||||
### Features
|
||||
|
||||
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
|
||||
|
||||
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
|
||||
|
||||
### Features
|
||||
|
||||
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
|
||||
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
|
||||
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
|
||||
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
|
||||
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
|
||||
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
|
||||
|
||||
### Performance
|
||||
|
||||
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
|
||||
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
|
||||
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
|
||||
|
||||
### CI
|
||||
|
||||
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
|
||||
|
||||
### Tests
|
||||
|
||||
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
|
||||
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
|
||||
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
|
||||
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
|
||||
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
|
||||
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
|
||||
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
|
||||
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
|
||||
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
|
||||
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
|
||||
|
||||
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
|
||||
|
||||
### Features
|
||||
|
||||
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
|
||||
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
|
||||
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
|
||||
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
|
||||
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
|
||||
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
|
||||
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
|
||||
|
||||
### Documentation
|
||||
|
||||
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
|
||||
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
|
||||
|
||||
### CI
|
||||
|
||||
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
|
||||
|
||||
### Tests
|
||||
|
||||
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
|
||||
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
|
||||
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
|
||||
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
|
||||
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
|
||||
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
|
||||
|
||||
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* remove investment stateVersion, queued sync state,
|
||||
and tbank-sync status progress/resources. Consumers must observe
|
||||
resources through sync=observe with the updated frontend contract.
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
|
||||
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
|
||||
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
|
||||
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
|
||||
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
|
||||
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
|
||||
|
||||
### Performance
|
||||
|
||||
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
|
||||
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
|
||||
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
|
||||
|
||||
### Refactoring
|
||||
|
||||
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
|
||||
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
|
||||
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
|
||||
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
|
||||
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
|
||||
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
|
||||
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
|
||||
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
|
||||
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
|
||||
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
|
||||
|
||||
### CI
|
||||
|
||||
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
|
||||
|
||||
### Tests
|
||||
|
||||
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
|
||||
|
||||
### Styles
|
||||
|
||||
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
|
||||
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
|
||||
|
||||
### Maintenance
|
||||
|
||||
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
|
||||
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
|
||||
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
|
||||
|
||||
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* investment reads return data/meta resource envelopes and locally
|
||||
persisted snapshots; consumers must use the matching OpenAPI contract and explicit
|
||||
synchronization endpoints.
|
||||
|
||||
### Features
|
||||
|
||||
* persist T-Bank data and synchronize reads ([d6e6c4a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d6e6c4ace553572ff9e9f635b7a4d21c8afbf97f))
|
||||
|
||||
### Tests
|
||||
|
||||
* prove reads finish before source synchronization ([e423213](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e4232138d04ad6a2aa8100a7d36fdcb2d7663fa8))
|
||||
|
||||
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
FROM node:24.21.0-bookworm-slim AS base
|
||||
FROM node:24.22.1-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dependencies
|
||||
@@ -19,7 +19,8 @@ EXPOSE 3000
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
FROM dependencies AS build
|
||||
RUN npm run build && npm prune --omit=dev --omit=optional
|
||||
# The client is already generated at the shared dependencies stage (same revision).
|
||||
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
|
||||
|
||||
FROM base AS production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
||||
|
||||
@@ -14,6 +14,7 @@ cp .env.example .env
|
||||
nvm use --silent
|
||||
npm ci
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
@@ -60,12 +61,15 @@ Compose topology принадлежит workspace, эксплуатационн
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Он запускает zero-warning lint,
|
||||
Он запускает генерацию Prisma client, zero-warning lint,
|
||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
||||
выполните обе команды.
|
||||
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
|
||||
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
|
||||
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
|
||||
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
|
||||
и не является функциональным merge gate.
|
||||
|
||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||
@@ -74,17 +78,40 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
||||
|
||||
### CI и обновления зависимостей
|
||||
|
||||
`.gitea/workflows/verify.yml` запускает `npm ci`, генерацию Prisma client,
|
||||
`npm run verify` и `npm run test:e2e` на push и PR. Используется только checkout
|
||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||
Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
|
||||
и `verify` на metadata-only событии не запускаются.
|
||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
|
||||
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
|
||||
на уже собранных артефактах. Используется только checkout
|
||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||
|
||||
`security.yml` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||
значениями и выполняет `npm audit --include=dev --audit-level=low` при push,
|
||||
PR и ежедневно. `.gitleaksignore` перечисляет только проверенные исторические
|
||||
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||
значениями, блокирует любой runtime advisory через
|
||||
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
|
||||
через `npm audit --include=dev --audit-level=high`. Moderate advisory
|
||||
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
|
||||
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
|
||||
`.gitleaksignore` перечисляет только проверенные исторические
|
||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||
Новые коммиты этими исключениями не покрываются.
|
||||
|
||||
Имена jobs `commits`, `security`, `verify` сохранены, но Gitea включает имя
|
||||
workflow в статус. При внедрении администратор должен заменить required checks
|
||||
для защищённых веток на `Backend verify / commits (pull_request)`,
|
||||
`Backend verify / security (pull_request)` и
|
||||
`Backend verify / verify (pull_request)`, удалив старые имена и push-статусы,
|
||||
если они требовались. Все три PR-проверки должны оставаться обязательными.
|
||||
Публикация образов по тегам остаётся в `image.yml`.
|
||||
Последовательность jobs ограничивает один запуск; для ограничения нагрузки всего сервера настройте
|
||||
`runner.capacity: 1` у общего runner и учтите остальные зарегистрированные
|
||||
runners. Конфигурация runner управляется вне этого репозитория.
|
||||
|
||||
Renovate группирует minor/patch updates, а major требует согласования в
|
||||
Dependency Dashboard. Автоматический merge не включён. Прямые зависимости
|
||||
фиксируются точными версиями: `.npmrc` задаёт `save-exact=true` для npm install,
|
||||
@@ -133,6 +160,10 @@ lint. Он не защищает от усложнения уже учтённо
|
||||
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||
|
||||
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||
|
||||
## SonarQube
|
||||
|
||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||
@@ -187,7 +218,10 @@ nvm use --silent
|
||||
npm run openapi:generate
|
||||
```
|
||||
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория
|
||||
атомарно: через временный файл и rename, без частичной записи при сбое.
|
||||
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
|
||||
с ошибкой при расхождении, не перезаписывая его.
|
||||
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||
@@ -220,10 +254,14 @@ breaking changes относительно предыдущего выпуска.
|
||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||
|
||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
||||
три последовательных и два параллельных запуска двух обычных e2e suites,
|
||||
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
|
||||
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
|
||||
Логи остаются в каталоге из вывода. Для одной suite:
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`.
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
|
||||
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
|
||||
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
|
||||
расходиться с parent/src.
|
||||
|
||||
| Риск | Проверка реальной DB |
|
||||
| --- | --- |
|
||||
@@ -267,35 +305,59 @@ Durability оставляет временные DB, логи и `result.json`
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
||||
`release/hotfix-<name>` и проходит тот же процесс.
|
||||
```text
|
||||
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||
```
|
||||
|
||||
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
|
||||
|
||||
```bash
|
||||
git fetch -q origin
|
||||
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
|
||||
```
|
||||
|
||||
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
|
||||
включая `spec:`. Версии вручную не повышай.
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
|
||||
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
|
||||
`nvm use --silent`, `npm ci`.
|
||||
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
||||
изменился, согласуй историю и повтори проверки кандидата.
|
||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
||||
существующий кандидат без повторного повышения версии.
|
||||
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
|
||||
без нового bump; до merge исправь и проверь существующий кандидат без
|
||||
повторного повышения версии.
|
||||
|
||||
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
|
||||
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
|
||||
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
|
||||
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
|
||||
затем merge `main → develop` для восстановления инварианта.
|
||||
|
||||
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
||||
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
|
||||
defaultIgnores: false,
|
||||
ignores: [
|
||||
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||
],
|
||||
rules: {
|
||||
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
|
||||
'references-empty': [2, 'never'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -4,11 +4,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/common/logging/request-logging.middleware.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
@@ -37,11 +32,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/income/income-analytics.service.ts": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -54,7 +44,7 @@
|
||||
},
|
||||
"src/modules/investments/instruments/application/candle.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 4
|
||||
"count": 3
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 4
|
||||
@@ -64,17 +54,11 @@
|
||||
}
|
||||
},
|
||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 2
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -107,11 +91,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/investment-resource.mapper.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 3
|
||||
}
|
||||
},
|
||||
"src/modules/investments/investment-sync-plan.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
@@ -154,30 +133,14 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/portfolio/application/portfolio.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 3
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"test/bank-cards-durability.cjs": {
|
||||
|
||||
+110
-2
@@ -15,6 +15,9 @@ import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||
const prismaImports = [
|
||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||
];
|
||||
|
||||
export default defineConfig(
|
||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||
@@ -34,7 +37,7 @@ export default defineConfig(
|
||||
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||
settings: {
|
||||
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
|
||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
|
||||
},
|
||||
rules: {
|
||||
'curly': ['error', 'multi-line'],
|
||||
@@ -48,7 +51,11 @@ export default defineConfig(
|
||||
'perfectionist/sort-classes': 'off',
|
||||
'@stylistic/brace-style': ['error', '1tbs', { allowSingleLine: false }],
|
||||
'@stylistic/arrow-parens': ['error', 'always'],
|
||||
'@stylistic/operator-linebreak': ['error', 'after'],
|
||||
'@stylistic/operator-linebreak': [
|
||||
'error',
|
||||
'after',
|
||||
{ overrides: { '?': 'before', ':': 'before' } },
|
||||
],
|
||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||
},
|
||||
},
|
||||
@@ -56,6 +63,18 @@ export default defineConfig(
|
||||
files: ['**/*.mjs'],
|
||||
extends: [node.configs['flat/recommended-module']],
|
||||
},
|
||||
{
|
||||
files: ['scripts/generate-openapi.cjs', 'test/fixtures/tbank-sync-crash.cjs', 'test/runtime-check.cjs'],
|
||||
rules: {
|
||||
// These scripts run after build; lint validates their source paths before dist exists.
|
||||
'n/no-missing-require': ['error', {
|
||||
resolverConfig: {
|
||||
alias: { '../dist': import.meta.dirname, '../../dist': import.meta.dirname },
|
||||
extensionAlias: { '.js': ['.ts', '.js'] },
|
||||
},
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['**/*.ts'],
|
||||
extends: [tseslint.configs.recommendedTypeChecked, importX.flatConfigs.typescript],
|
||||
@@ -103,4 +122,93 @@ export default defineConfig(
|
||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/modules/**/domain/**/*.ts'],
|
||||
ignores: tests,
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: [
|
||||
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
|
||||
'**/*.client', '**/application/**', '**/infrastructure/**',
|
||||
],
|
||||
message: 'Domain uses pure rules and value types, not transport or persistence.',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.controller.ts'],
|
||||
ignores: tests,
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [
|
||||
{
|
||||
group: [...prismaImports, '**/*.repository', '**/*.client'],
|
||||
message: 'Controllers delegate to application services or use cases.',
|
||||
},
|
||||
{
|
||||
group: ['**'],
|
||||
importNamePattern: '(?:Client|Repository|PrismaService)$',
|
||||
message: 'Use application orchestration rather than data providers in controllers.',
|
||||
},
|
||||
],
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.use-case.ts'],
|
||||
ignores: tests,
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: prismaImports,
|
||||
message: 'Use cases obtain data through concrete repositories.',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
},
|
||||
{
|
||||
files: [
|
||||
'src/**/*.ts',
|
||||
],
|
||||
ignores: [
|
||||
'**/*.spec.ts',
|
||||
'test/**/*.ts',
|
||||
],
|
||||
rules: {
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
{
|
||||
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
|
||||
message: 'Use the owning enum member for machine codes and protocol values.',
|
||||
},
|
||||
{
|
||||
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
|
||||
message: 'Use the owning enum member instead of a template machine code.',
|
||||
},
|
||||
{
|
||||
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
|
||||
message: 'Use an enum member instead of an inline code or protocol value.',
|
||||
},
|
||||
{
|
||||
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
|
||||
message: 'Use an enum member instead of constructing a code or protocol value.',
|
||||
},
|
||||
{
|
||||
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
|
||||
message: 'Use a computed enum member as the protocol mapping key.',
|
||||
},
|
||||
{
|
||||
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
|
||||
message: 'Compare codes with their enum members, not string literals.',
|
||||
},
|
||||
{
|
||||
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
Generated
+1086
-1522
File diff suppressed because it is too large
Load Diff
+58
-43
@@ -1,20 +1,21 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "0.2.3",
|
||||
"version": "2.3.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "v24.21.0"
|
||||
"node": "v24.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||
"format": "npm run lint:fix",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint . --max-warnings=0",
|
||||
@@ -26,92 +27,107 @@
|
||||
"test:cov": "npm test -- --coverage",
|
||||
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
|
||||
"test:sqlite": "node test/sqlite.cjs",
|
||||
"test:container": "node test/container-smoke.cjs",
|
||||
"test:durability": "node test/bank-cards-durability.cjs",
|
||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||
"verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
|
||||
"release:preview": "release-it --dry-run --ci",
|
||||
"release:prepare": "release-it --ci",
|
||||
"commitlint": "commitlint",
|
||||
"test:lint": "node --test test/eslint-config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.4",
|
||||
"@grpc/grpc-js": "1.14.6",
|
||||
"@grpc/proto-loader": "0.8.1",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/jwt": "12.0.1",
|
||||
"@nestjs/common": "12.2.0",
|
||||
"@nestjs/config": "12.0.1",
|
||||
"@nestjs/core": "12.2.0",
|
||||
"@nestjs/jwt": "12.0.3",
|
||||
"@nestjs/passport": "12.0.0",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@nestjs/platform-express": "12.2.0",
|
||||
"@nestjs/swagger": "12.0.4",
|
||||
"@prisma/adapter-better-sqlite3": "7.10.1",
|
||||
"@prisma/client": "7.10.1",
|
||||
"bcrypt": "6.0.0",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"better-sqlite3": "12.11.1",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"dotenv": "17.4.2",
|
||||
"lru-cache": "11.5.2",
|
||||
"nestjs-pino": "5.1.0",
|
||||
"lru-cache": "11.5.3",
|
||||
"nestjs-pino": "5.3.1",
|
||||
"passport": "0.7.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pino-http": "11.0.0",
|
||||
"prisma": "7.10.0",
|
||||
"prisma": "7.10.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
|
||||
"@commitlint/cli": "21.2.4",
|
||||
"@commitlint/config-conventional": "21.2.4",
|
||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@nestjs/cli": "12.0.0",
|
||||
"@nestjs/schematics": "12.0.1",
|
||||
"@nestjs/testing": "12.0.1",
|
||||
"@release-it/conventional-changelog": "12.0.0",
|
||||
"@nestjs/cli": "12.0.10",
|
||||
"@nestjs/schematics": "12.0.10",
|
||||
"@nestjs/testing": "12.2.0",
|
||||
"@release-it/conventional-changelog": "12.0.2",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/supertest": "7.2.1",
|
||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||
"eslint": "10.11.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jest": "29.16.6",
|
||||
"eslint-plugin-n": "18.4.0",
|
||||
"eslint-plugin-perfectionist": "5.12.1",
|
||||
"eslint-plugin-jest": "29.17.0",
|
||||
"eslint-plugin-n": "18.5.0",
|
||||
"eslint-plugin-perfectionist": "5.13.0",
|
||||
"eslint-plugin-regexp": "3.3.1",
|
||||
"eslint-plugin-security": "4.1.0",
|
||||
"eslint-plugin-sonarjs": "4.2.1",
|
||||
"globals": "17.12.0",
|
||||
"jest": "30.5.1",
|
||||
"release-it": "21.0.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.4.12",
|
||||
"eslint-plugin-security": "4.2.0",
|
||||
"eslint-plugin-sonarjs": "4.2.2",
|
||||
"globals": "17.13.0",
|
||||
"jest": "30.5.2",
|
||||
"pino-pretty": "13.2.0",
|
||||
"release-it": "21.1.1",
|
||||
"supertest": "7.3.1",
|
||||
"ts-jest": "29.4.15",
|
||||
"ts-loader": "9.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.1"
|
||||
"typescript-eslint": "8.72.0"
|
||||
},
|
||||
"overrides": {
|
||||
"proxy-addr": "2.0.8",
|
||||
"express": {
|
||||
"body-parser": "2.4.0"
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"multer": "2.3.0"
|
||||
"multer": "2.5.0"
|
||||
},
|
||||
"ajv": {
|
||||
"fast-uri": "3.1.9"
|
||||
},
|
||||
"@prisma/config@7.10.0": {
|
||||
"deepmerge-ts": "8.0.2"
|
||||
},
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.3",
|
||||
"undici": "7.30.1"
|
||||
},
|
||||
"socks": {
|
||||
"ip-address": "10.7.4"
|
||||
},
|
||||
"prisma": {
|
||||
"mysql2": "3.24.4"
|
||||
"mysql2": "3.24.6"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
@@ -138,7 +154,6 @@
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"better-sqlite3@13.0.3": true
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TbankPortfolioValueHistory" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"deletedAt" DATETIME,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("userId", "accountId"),
|
||||
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TbankPortfolioValuePoint" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"attemptId" TEXT NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"capturedAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
|
||||
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
|
||||
@@ -17,6 +17,7 @@ model User {
|
||||
bankCardOperations BankCardOperation[]
|
||||
sessions Session[]
|
||||
tbankConnection TbankConnection?
|
||||
tbankValueHistory TbankPortfolioValueHistory[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -69,6 +70,34 @@ model TbankPortfolio {
|
||||
@@id([userId, revision, accountId])
|
||||
}
|
||||
|
||||
// Account valuation history survives connection replacement and stays hidden
|
||||
// until the current connection confirms the account again.
|
||||
model TbankPortfolioValueHistory {
|
||||
userId String
|
||||
accountId String
|
||||
deletedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
points TbankPortfolioValuePoint[]
|
||||
|
||||
@@id([userId, accountId])
|
||||
}
|
||||
|
||||
// One row per currency of a fully valued successful portfolio attempt.
|
||||
model TbankPortfolioValuePoint {
|
||||
userId String
|
||||
accountId String
|
||||
attemptId String
|
||||
currency String
|
||||
value String
|
||||
capturedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||
|
||||
@@id([userId, accountId, attemptId, currency])
|
||||
@@index([userId, accountId, capturedAt])
|
||||
}
|
||||
|
||||
model TbankOperation {
|
||||
userId String
|
||||
revision String
|
||||
|
||||
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
|
||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||
|
||||
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
|
||||
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
||||
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { writeFile } = require('node:fs/promises');
|
||||
const { readFile, rename, rm, writeFile } = require('node:fs/promises');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
|
||||
const target = resolve(process.cwd(), 'openapi.json');
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
|
||||
// Schema generation needs no deployment configuration or real credentials.
|
||||
Object.assign(process.env, {
|
||||
@@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core');
|
||||
const { AppModule } = require('../dist/src/app.module.js');
|
||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||
|
||||
async function generate() {
|
||||
async function render() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
preview: true,
|
||||
logger: false,
|
||||
});
|
||||
try {
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
await writeFile(
|
||||
'openapi.json',
|
||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
||||
);
|
||||
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
generate().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
// Read-only: compares the runtime document with the committed artifact and never writes.
|
||||
async function check(content) {
|
||||
const current = await readFile(target, 'utf8').catch(() => null);
|
||||
if (current !== content) {
|
||||
console.error(
|
||||
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
|
||||
);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
console.log('openapi.json matches the runtime schema.');
|
||||
}
|
||||
|
||||
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
|
||||
async function update(content) {
|
||||
const temp = `${target}.${process.pid}.tmp`;
|
||||
try {
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||
await writeFile(temp, content);
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
|
||||
await rename(temp, target);
|
||||
} finally {
|
||||
await rm(temp, { force: true });
|
||||
}
|
||||
}
|
||||
|
||||
render()
|
||||
.then((content) => (checkOnly ? check(content) : update(content)))
|
||||
.catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
+2
-36
@@ -1,10 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
|
||||
import { LoggingModule } from './common/logging/logging.module';
|
||||
import { Environment, validateEnv } from './config/env.validation';
|
||||
import { validateEnv } from './config/env.validation';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
|
||||
isGlobal: true,
|
||||
validate: validateEnv,
|
||||
}),
|
||||
LoggerModule.forRootAsync({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
||||
pinoHttp: {
|
||||
level: config.getOrThrow('LOG_LEVEL'),
|
||||
messageKey: 'message',
|
||||
autoLogging: false,
|
||||
genReqId: (request: unknown, response) => {
|
||||
const existingId = (request as { id?: unknown }).id;
|
||||
const requestId =
|
||||
typeof existingId === 'string' ? existingId : randomUUID();
|
||||
response.setHeader('X-Request-ID', requestId);
|
||||
return requestId;
|
||||
},
|
||||
redact: [
|
||||
'req.headers.authorization',
|
||||
'req.headers.cookie',
|
||||
'req.body',
|
||||
],
|
||||
serializers: {
|
||||
req: (request: unknown) => {
|
||||
const value = request as { id?: unknown; method?: unknown };
|
||||
return {
|
||||
id: typeof value.id === 'string' ? value.id : undefined,
|
||||
method:
|
||||
typeof value.method === 'string' ? value.method : undefined,
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
LoggingModule,
|
||||
PrismaModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -7,11 +7,13 @@ import {
|
||||
getSchemaPath,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { HttpErrorCode } from '../error-code';
|
||||
|
||||
export class ProblemDetailsDto {
|
||||
@ApiProperty({ example: 401 })
|
||||
declare status: number;
|
||||
|
||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
||||
@ApiProperty({ example: HttpErrorCode.Unauthorized })
|
||||
declare code: string;
|
||||
|
||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
||||
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||
import {
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
|
||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||
|
||||
it('requires credentials and normalizes a valid email', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
||||
const metadata = { type: 'body' as const, metatype: RegisterDto };
|
||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@@ -29,6 +29,59 @@ describe('DTO validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
|
||||
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
|
||||
// bytes so accounts created under the older policy can still sign in.
|
||||
describe('password policy', () => {
|
||||
const register = { type: 'body' as const, metatype: RegisterDto };
|
||||
const login = { type: 'body' as const, metatype: LoginDto };
|
||||
const body = (password: string) => ({ email: 'user@example.com', password });
|
||||
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
|
||||
pipe.transform(value, metadata).then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
if (!(error instanceof BadRequestException)) throw error;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['7 chars', 'a'.repeat(7), false],
|
||||
['8 chars', 'a'.repeat(8), true],
|
||||
['72 chars', 'a'.repeat(72), true],
|
||||
['73 chars', 'a'.repeat(73), false],
|
||||
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
|
||||
['Cyrillic, 8+ chars', 'пароль-пароль', false],
|
||||
['emoji', 'passw😀rd', false],
|
||||
['tab', 'pass\tword1', false],
|
||||
['newline', 'password1\n', false],
|
||||
['DEL', 'password\x7f1', false],
|
||||
['non-string', 12345678, false],
|
||||
])('register: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
|
||||
});
|
||||
|
||||
it('register keeps raw whitespace untouched', async () => {
|
||||
const password = ' spaced pass ';
|
||||
await expect(pipe.transform(body(password), register)).resolves.toEqual(
|
||||
body(password),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['legacy Cyrillic password', 'пароль-пароль', true],
|
||||
['short legacy password', 'short', true],
|
||||
['72 bytes', 'a'.repeat(72), true],
|
||||
['73 bytes', 'a'.repeat(73), false],
|
||||
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
|
||||
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
|
||||
['empty', '', false],
|
||||
['non-string', 12345678, false],
|
||||
])('login: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
|
||||
});
|
||||
});
|
||||
|
||||
it('requires and validates nested money in a new card', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||
const card = {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export enum HttpErrorCode {
|
||||
BadRequest = 'BAD_REQUEST',
|
||||
InternalServerError = 'INTERNAL_SERVER_ERROR',
|
||||
NotFound = 'NOT_FOUND',
|
||||
Unauthorized = 'UNAUTHORIZED',
|
||||
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
|
||||
ValidationError = 'VALIDATION_ERROR',
|
||||
}
|
||||
@@ -1,24 +1,29 @@
|
||||
import { ArgumentsHost } from '@nestjs/common';
|
||||
import { ArgumentsHost, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
it('does not expose internal error details', () => {
|
||||
const setup = () => {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const type = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const locals: Record<string, unknown> = {};
|
||||
const filter = new HttpExceptionFilter();
|
||||
const logger = { event: jest.fn() };
|
||||
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
|
||||
const host = {
|
||||
switchToHttp: () => ({
|
||||
getResponse: () => ({ status, type, json, locals }),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
return { filter, host, status, type, json, locals, logger };
|
||||
};
|
||||
|
||||
filter.catch(
|
||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
||||
host,
|
||||
);
|
||||
it('logs unhandled errors with name and stack frames but never the message', () => {
|
||||
const { filter, host, status, type, json, locals, logger } = setup();
|
||||
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||
@@ -27,6 +32,29 @@ describe('HttpExceptionFilter', () => {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||
expect(locals.errorStack).toBeUndefined();
|
||||
expect(logger.event).toHaveBeenCalledTimes(1);
|
||||
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
|
||||
expect(level).toBe('error');
|
||||
expect(event).toBe('http.request.unhandled');
|
||||
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
|
||||
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
|
||||
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
|
||||
});
|
||||
|
||||
it('logs field pointers without values for validation errors', () => {
|
||||
const { filter, host, status, logger, locals } = setup();
|
||||
const exception = new BadRequestException({
|
||||
message: [
|
||||
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
|
||||
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
|
||||
],
|
||||
});
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(locals.invalidFields).toBe('#/amount');
|
||||
expect(logger.event).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(locals)).not.toContain('-42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,42 +9,73 @@ import {
|
||||
ValidationError,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { HttpErrorCode } from '../error-code';
|
||||
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: StructuredLogger) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const response = context.getResponse<Response>();
|
||||
const status =
|
||||
exception instanceof HttpException ?
|
||||
exception.getStatus() :
|
||||
HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
exception instanceof HttpException
|
||||
? exception.getStatus()
|
||||
: HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
|
||||
const payload =
|
||||
exception instanceof HttpException ? exception.getResponse() : undefined;
|
||||
const isValidation = Array.isArray(
|
||||
(payload as { message?: unknown })?.message,
|
||||
);
|
||||
const code = isValidation ?
|
||||
'VALIDATION_ERROR' :
|
||||
this.codeFor(status, payload);
|
||||
const code = isValidation
|
||||
? HttpErrorCode.ValidationError
|
||||
: this.codeFor(status, payload);
|
||||
response.locals ??= {};
|
||||
response.locals.publicErrorCode = code;
|
||||
if (isValidation) {
|
||||
response.locals.invalidFields = [
|
||||
...new Set(
|
||||
this.validationErrors(
|
||||
(payload as { message: ValidationError[] }).message,
|
||||
).map((error) => error.pointer),
|
||||
),
|
||||
].join(',');
|
||||
}
|
||||
if (!(exception instanceof HttpException)) {
|
||||
this.logUnhandled(exception, code);
|
||||
}
|
||||
response
|
||||
.status(status)
|
||||
.type('application/problem+json')
|
||||
.json({
|
||||
status,
|
||||
code,
|
||||
...(isValidation ?
|
||||
{
|
||||
...(isValidation
|
||||
? {
|
||||
errors: this.validationErrors(
|
||||
(payload as { message: ValidationError[] }).message,
|
||||
),
|
||||
} :
|
||||
{}),
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
|
||||
private logUnhandled(exception: unknown, code: string): void {
|
||||
const error = exception instanceof Error ? exception : undefined;
|
||||
const frames = (error?.stack ?? '')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith(' at '))
|
||||
.join('\n');
|
||||
this.logger.event('error', 'http.request.unhandled', {
|
||||
code,
|
||||
errorName: error?.name ?? typeof exception,
|
||||
errorStack: frames,
|
||||
});
|
||||
}
|
||||
|
||||
private codeFor(status: number, payload: unknown): string {
|
||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||
const code = (payload as { code?: unknown }).code;
|
||||
@@ -53,12 +84,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
return (
|
||||
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
||||
{
|
||||
400: 'BAD_REQUEST',
|
||||
401: 'UNAUTHORIZED',
|
||||
404: 'NOT_FOUND',
|
||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
500: 'INTERNAL_SERVER_ERROR',
|
||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
||||
400: HttpErrorCode.BadRequest,
|
||||
401: HttpErrorCode.Unauthorized,
|
||||
404: HttpErrorCode.NotFound,
|
||||
415: HttpErrorCode.UnsupportedMediaType,
|
||||
500: HttpErrorCode.InternalServerError,
|
||||
}[status] ?? HttpErrorCode.InternalServerError
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,16 +1,71 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import type { Options } from 'pino-http';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||
const route: unknown = request.route;
|
||||
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||
typeof route.path === 'string'
|
||||
? `${request.baseUrl}${route.path}`
|
||||
: '<unmatched>';
|
||||
return {
|
||||
method: request.method,
|
||||
route: path,
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(value.responseTime),
|
||||
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||
return {
|
||||
level: config.getOrThrow('LOG_LEVEL'),
|
||||
messageKey: 'message',
|
||||
// ponytail: development-only pretty output; production never loads pino-pretty.
|
||||
...(process.env['NODE_ENV'] === 'development'
|
||||
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
|
||||
: {}),
|
||||
genReqId: (_request, response) => {
|
||||
const id = randomUUID();
|
||||
response.setHeader('X-Request-ID', id);
|
||||
return id;
|
||||
},
|
||||
customProps: (request) => {
|
||||
const incoming = request.header('x-request-id');
|
||||
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||
},
|
||||
customLogLevel: (_request, response) => {
|
||||
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||
return 'info';
|
||||
},
|
||||
customSuccessMessage: () => 'http.request.completed',
|
||||
customErrorMessage: () => 'http.request.completed',
|
||||
customSuccessObject: completion,
|
||||
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||
serializers: { req: () => undefined },
|
||||
};
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [
|
||||
RequestContextService,
|
||||
RequestLoggingMiddleware,
|
||||
StructuredLogger,
|
||||
],
|
||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
||||
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||
inject: [ConfigService],
|
||||
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||
})],
|
||||
providers: [StructuredLogger],
|
||||
exports: [StructuredLogger],
|
||||
})
|
||||
export class LoggingModule {}
|
||||
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
||||
|
||||
export type RequestLogContext = {
|
||||
brokerageAccountId?: string;
|
||||
clientRequestId?: string;
|
||||
requestId: string;
|
||||
userId?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class RequestContextService {
|
||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
||||
|
||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
||||
return this.storage.run(context, callback);
|
||||
}
|
||||
|
||||
get(): RequestLogContext | undefined {
|
||||
return this.storage.getStore();
|
||||
}
|
||||
|
||||
set(context: Partial<RequestLogContext>): void {
|
||||
const current = this.storage.getStore();
|
||||
if (current) Object.assign(current, context);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
||||
|
||||
describe('RequestLoggingMiddleware', () => {
|
||||
it('returns a server ID and emits one safe completion event', () => {
|
||||
const requestContext = {
|
||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
||||
};
|
||||
const logger = { event: jest.fn() };
|
||||
const middleware = new RequestLoggingMiddleware(
|
||||
requestContext as never,
|
||||
logger as never,
|
||||
);
|
||||
const response = Object.assign(new EventEmitter(), {
|
||||
statusCode: 200,
|
||||
locals: {},
|
||||
setHeader: jest.fn(),
|
||||
});
|
||||
const next = jest.fn();
|
||||
|
||||
const request: {
|
||||
baseUrl: string;
|
||||
header: jest.Mock<string>;
|
||||
id?: string;
|
||||
method: string;
|
||||
route: { path: string };
|
||||
} = {
|
||||
method: 'GET',
|
||||
baseUrl: '/api/v1',
|
||||
route: { path: '/accounts/:id' },
|
||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
||||
};
|
||||
middleware.use(request as never, response as never, next);
|
||||
response.emit('finish');
|
||||
|
||||
expect(response.setHeader).toHaveBeenCalledWith(
|
||||
'X-Request-ID',
|
||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
||||
);
|
||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
||||
expect(requestContext.run).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
||||
}),
|
||||
next,
|
||||
);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'http.request.completed',
|
||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,67 +0,0 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
const uuid =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||
|
||||
@Injectable()
|
||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
||||
constructor(
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
use(request: Request, response: Response, next: NextFunction): void {
|
||||
const incoming = request.header('x-request-id');
|
||||
const clientRequestId =
|
||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
||||
const requestId =
|
||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
||||
(request as unknown as { id: string }).id = requestId;
|
||||
const startedAt = performance.now();
|
||||
|
||||
response.setHeader('X-Request-ID', requestId);
|
||||
response.on('finish', () => {
|
||||
const routeRequest: unknown = request;
|
||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
||||
?.path;
|
||||
const route =
|
||||
typeof routePath === 'string' ?
|
||||
`${request.baseUrl}${routePath}` :
|
||||
'<unmatched>';
|
||||
const context = {
|
||||
method: request.method,
|
||||
route,
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
...(response.locals.publicErrorCode ?
|
||||
{ code: response.locals.publicErrorCode as string } :
|
||||
{}),
|
||||
};
|
||||
const level =
|
||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR ?
|
||||
'error' :
|
||||
response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN ?
|
||||
'warn' :
|
||||
'info';
|
||||
this.logger.event(level, 'http.request.completed', context);
|
||||
});
|
||||
|
||||
this.requestContext.run(
|
||||
{
|
||||
requestId,
|
||||
...(clientRequestId ? { clientRequestId } : {}),
|
||||
},
|
||||
next,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { StructuredLogger } from './structured-logger.service';
|
||||
|
||||
describe('StructuredLogger', () => {
|
||||
const context = { get: jest.fn() };
|
||||
const pino = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('passes only allowlisted fields to Pino', () => {
|
||||
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
|
||||
const logger = new StructuredLogger(context as never, pino as never);
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.info('event', {
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
password: 'secret',
|
||||
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
|
||||
|
||||
expect(pino.info).toHaveBeenCalledWith(
|
||||
{
|
||||
requestId: 'request',
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
},
|
||||
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
|
||||
});
|
||||
|
||||
it('delegates event levels to Pino', () => {
|
||||
context.get.mockReturnValue(undefined);
|
||||
const logger = new StructuredLogger(context as never, pino as never);
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.event('debug', 'debug event');
|
||||
logger.event('warn', 'warn event');
|
||||
|
||||
@@ -1,36 +1,40 @@
|
||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { RequestContextService } from './request-context.service';
|
||||
|
||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||
|
||||
const allowedFields = new Set([
|
||||
'asOf',
|
||||
'attempts',
|
||||
'brokerageAccountId',
|
||||
'channelState',
|
||||
'clientRequestId',
|
||||
'code',
|
||||
'count',
|
||||
'durationMs',
|
||||
'environment',
|
||||
'errorName',
|
||||
'errorStack',
|
||||
'found',
|
||||
'grpcCode',
|
||||
'headersReceived',
|
||||
'invalidFields',
|
||||
'method',
|
||||
'networkMs',
|
||||
'operation',
|
||||
'queueWaitMs',
|
||||
'requestId',
|
||||
'route',
|
||||
'service',
|
||||
'status',
|
||||
'trackingId',
|
||||
'userId',
|
||||
]);
|
||||
|
||||
@Injectable()
|
||||
export class StructuredLogger implements OnApplicationShutdown {
|
||||
constructor(
|
||||
private readonly requestContext: RequestContextService,
|
||||
private readonly pino: PinoLogger,
|
||||
) {}
|
||||
constructor(private readonly pino: PinoLogger) {}
|
||||
|
||||
info(message: string, context?: LogContext): void {
|
||||
this.write('info', message, context);
|
||||
@@ -51,7 +55,7 @@ export class StructuredLogger implements OnApplicationShutdown {
|
||||
): void {
|
||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||
this.pino[level](
|
||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
||||
this.safeContext(context),
|
||||
message,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -105,9 +105,9 @@ export function validateEnv(config: Record<string, unknown>): Environment {
|
||||
JWT_SECRET: jwtSecret,
|
||||
PORT: parsedPort,
|
||||
TOKEN_ENCRYPTION_KEY: tokenEncryptionKey,
|
||||
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath ?
|
||||
{ TBANK_CA_CERT_PATH: tbankCaCertPath } :
|
||||
{}),
|
||||
...(typeof tbankCaCertPath === 'string' && tbankCaCertPath
|
||||
? { TBANK_CA_CERT_PATH: tbankCaCertPath }
|
||||
: {}),
|
||||
TBANK_GRPC_ENDPOINT: tbankEndpoint,
|
||||
TBANK_RATE_LIMIT_PER_SECOND: parsedTbankRateLimit,
|
||||
LOG_LEVEL: logLevel as Environment['LOG_LEVEL'],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||
|
||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||
const FIRST_PAGE_OFFSET = 0;
|
||||
const EXPECTED_CURSOR_ROWS = 1;
|
||||
@@ -152,7 +154,7 @@ export class MoexClient {
|
||||
pageSize === EMPTY_PAGE_SIZE ||
|
||||
current.length !== Math.min(pageSize, total - start)
|
||||
) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
if (index + pageSize >= total) return rows;
|
||||
start = index + pageSize;
|
||||
@@ -172,22 +174,22 @@ export class MoexClient {
|
||||
}
|
||||
return body as IssResponse;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
const columns = block.columns;
|
||||
if (!columns.every((column) => typeof column === 'string')) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
return block.data.map((values) => {
|
||||
if (!Array.isArray(values) || values.length !== columns.length) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
return Object.fromEntries(
|
||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||
|
||||
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||
|
||||
const EXPECTED_REGISTRY_TABLES = 1;
|
||||
const RATING_COLUMN = 2;
|
||||
|
||||
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
|
||||
if (this.cache.get('registry') === pending) {
|
||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
|
||||
});
|
||||
this.cache.set('registry', pending);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
@@ -43,12 +43,12 @@ export class InstrumentMetadataRepository {
|
||||
|
||||
async readMany(identities: MetadataIdentity[]) {
|
||||
const identifiers = identities.flatMap((identity) => [
|
||||
...(identity.instrumentUid?.trim() ?
|
||||
[{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }] :
|
||||
[]),
|
||||
...(identity.figi?.trim() ?
|
||||
[{ identifierType: 'figi', identifier: identity.figi.trim() }] :
|
||||
[]),
|
||||
...(identity.instrumentUid?.trim()
|
||||
? [{ identifierType: 'uid', identifier: identity.instrumentUid.trim() }]
|
||||
: []),
|
||||
...(identity.figi?.trim()
|
||||
? [{ identifierType: 'figi', identifier: identity.figi.trim() }]
|
||||
: []),
|
||||
]);
|
||||
if (!identifiers.length) {
|
||||
return new Map<string, ReturnType<typeof selectMetadata>>();
|
||||
@@ -233,9 +233,9 @@ export class InstrumentMetadataRepository {
|
||||
update: {
|
||||
...data,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload) ?
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
increment: isDeepStrictEqual(previous?.metadata, payload)
|
||||
? UNCHANGED_REVISION_INCREMENT
|
||||
: REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -256,9 +256,9 @@ export class InstrumentMetadataRepository {
|
||||
matchedUids,
|
||||
fetchedAt: now,
|
||||
dataRevision: {
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids) ?
|
||||
UNCHANGED_REVISION_INCREMENT :
|
||||
REVISION_INCREMENT,
|
||||
increment: isDeepStrictEqual(previous?.matchedUids, matchedUids)
|
||||
? UNCHANGED_REVISION_INCREMENT
|
||||
: REVISION_INCREMENT,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -276,7 +276,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
if (success) {
|
||||
@@ -286,7 +286,7 @@ export class InstrumentMetadataRepository {
|
||||
state: 'running',
|
||||
connection: { invalidToken: true },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -319,11 +319,7 @@ function selectMetadata(
|
||||
return {
|
||||
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||
complete,
|
||||
fresh:
|
||||
complete &&
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
|
||||
fetchedAt !== null &&
|
||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||
fresh: complete,
|
||||
fetchedAt,
|
||||
dataRevision: used
|
||||
.map(
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
|
||||
if (
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
} else {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
@@ -103,7 +104,7 @@ export class TbankAccessService {
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
@@ -120,6 +121,23 @@ export class TbankAccessService {
|
||||
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||
}
|
||||
|
||||
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
|
||||
async online<T>(
|
||||
userId: string,
|
||||
run: (connection: { revision: string; token: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||
});
|
||||
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
const token = this.tokens.decrypt(connection.encryptedToken);
|
||||
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
|
||||
}
|
||||
|
||||
async publishMembership(
|
||||
attempt: TbankSyncAttempt,
|
||||
accounts: TbankAccount[],
|
||||
@@ -191,6 +209,17 @@ export class TbankAccessService {
|
||||
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
||||
},
|
||||
});
|
||||
// Value history visibility follows the confirmed membership only; a
|
||||
// failed fetch never reaches this transaction and changes nothing.
|
||||
const supportedIds = supported.map(({ id }) => id);
|
||||
await tx.tbankPortfolioValueHistory.updateMany({
|
||||
where: { userId, accountId: { in: supportedIds } },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
await tx.tbankPortfolioValueHistory.updateMany({
|
||||
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
|
||||
data: { deletedAt: fetchedAt },
|
||||
});
|
||||
if (completeWork) {
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
@@ -205,7 +234,7 @@ export class TbankAccessService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
await tx.tbankConnection.update({
|
||||
@@ -236,10 +265,10 @@ export class TbankAccessService {
|
||||
},
|
||||
});
|
||||
if (!connection) {
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
}
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { TbankAccountType } from './tbank-protocol';
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
||||
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
[TbankAccountType.TinkoffIis]: {
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
},
|
||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
ACCOUNT_TYPE_INVEST_FUND: {
|
||||
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
[TbankAccountType.InvestFund]: {
|
||||
type: 'MONEY_MARKET_FUND',
|
||||
name: 'Фонд денежного рынка',
|
||||
},
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
export enum TbankErrorCode {
|
||||
InvalidToken = 'INVALID_TBANK_TOKEN',
|
||||
NotConnected = 'TBANK_NOT_CONNECTED',
|
||||
Unavailable = 'TBANK_UNAVAILABLE',
|
||||
}
|
||||
|
||||
// Error messages and persisted codes cross an open string boundary.
|
||||
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
|
||||
return value === expected;
|
||||
}
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
export type TbankShare = TbankInstrument & {
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
assetUid?: string;
|
||||
buyAvailableFlag?: boolean;
|
||||
classCode?: string;
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
figi?: string;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Injectable()
|
||||
@@ -37,7 +38,7 @@ export class TbankMembershipService {
|
||||
.catch(() =>
|
||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||
userId,
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
}),
|
||||
)
|
||||
.finally(() => this.active.delete(attemptId));
|
||||
@@ -62,7 +63,7 @@ export class TbankMembershipService {
|
||||
} catch (error) {
|
||||
await this.access.failMembership(
|
||||
attempt,
|
||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { loadSync } from '@grpc/proto-loader';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import {
|
||||
InstrumentIdType,
|
||||
isTbankProtocolValue,
|
||||
TbankAccountStatus,
|
||||
TbankAccountType,
|
||||
TbankCandleInterval,
|
||||
TbankCouponType,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
TbankOperationType,
|
||||
} from './tbank-protocol';
|
||||
|
||||
it('uses the string enum values declared by the vendored protocol', () => {
|
||||
const directory = join(__dirname, 'proto/contracts');
|
||||
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
|
||||
for (const [name, values] of Object.entries({
|
||||
InstrumentIdType,
|
||||
InstrumentType: TbankInstrumentType,
|
||||
AccountStatus: TbankAccountStatus,
|
||||
AccountType: TbankAccountType,
|
||||
CandleInterval: TbankCandleInterval,
|
||||
CouponType: TbankCouponType,
|
||||
OperationState: TbankOperationState,
|
||||
OperationType: TbankOperationType,
|
||||
})) {
|
||||
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
|
||||
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
|
||||
const descriptor = definition as { type: { value: { name: string }[] } };
|
||||
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
|
||||
}
|
||||
});
|
||||
|
||||
it('recognizes supported wire values without accepting unknown ones', () => {
|
||||
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
|
||||
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
|
||||
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
|
||||
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
// String wire values for the protocol subset used by this integration.
|
||||
// proto-loader decodes enums as strings; vendor contracts remain the source.
|
||||
export enum InstrumentIdType {
|
||||
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
|
||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentStatus {
|
||||
All = 'INSTRUMENT_STATUS_ALL',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentType {
|
||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||
}
|
||||
|
||||
export enum TbankAccountStatus {
|
||||
Open = 'ACCOUNT_STATUS_OPEN',
|
||||
}
|
||||
|
||||
export enum TbankAccountType {
|
||||
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
|
||||
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
|
||||
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
|
||||
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||
}
|
||||
|
||||
export enum TbankCandleInterval {
|
||||
Day = 'CANDLE_INTERVAL_DAY',
|
||||
}
|
||||
|
||||
export enum TbankOperationState {
|
||||
Executed = 'OPERATION_STATE_EXECUTED',
|
||||
}
|
||||
|
||||
export enum TbankCouponType {
|
||||
Floating = 'COUPON_TYPE_FLOATING',
|
||||
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
|
||||
}
|
||||
|
||||
export enum TbankOperationType {
|
||||
BondTax = 'OPERATION_TYPE_BOND_TAX',
|
||||
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
|
||||
Buy = 'OPERATION_TYPE_BUY',
|
||||
BuyCard = 'OPERATION_TYPE_BUY_CARD',
|
||||
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
|
||||
Coupon = 'OPERATION_TYPE_COUPON',
|
||||
DivExt = 'OPERATION_TYPE_DIV_EXT',
|
||||
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||
Input = 'OPERATION_TYPE_INPUT',
|
||||
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||
Output = 'OPERATION_TYPE_OUTPUT',
|
||||
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||
Sell = 'OPERATION_TYPE_SELL',
|
||||
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
|
||||
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
|
||||
Tax = 'OPERATION_TYPE_TAX',
|
||||
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
|
||||
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
|
||||
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
|
||||
}
|
||||
|
||||
// Provider responses may contain values newer than our supported protocol subset.
|
||||
export function isTbankProtocolValue(
|
||||
value: unknown,
|
||||
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
|
||||
): boolean {
|
||||
return value === expected;
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
tbankSourceTtlMs,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
describe('request-driven source freshness', () => {
|
||||
describe('mutable source freshness', () => {
|
||||
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||
'%s expires at its boundary without changing source time',
|
||||
(source) => {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||
export const tbankSourceTtlMs = {
|
||||
membership: 300_000,
|
||||
portfolio: 60_000,
|
||||
quote: 60_000,
|
||||
candleTail: 60_000,
|
||||
operationsTail: 300_000,
|
||||
metadata: 86_400_000,
|
||||
catalog: 86_400_000,
|
||||
company: 86_400_000,
|
||||
calendar: 86_400_000,
|
||||
membership: 3_600_000,
|
||||
portfolio: 3_600_000,
|
||||
quote: 3_600_000,
|
||||
candleTail: 3_600_000,
|
||||
} as const;
|
||||
|
||||
export function tbankSourceNeedsRefresh(
|
||||
|
||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@Injectable()
|
||||
@@ -26,8 +27,8 @@ export class TbankTokenService {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { Metadata, status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { TbankOperationState } from './tbank-protocol';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
@@ -10,7 +12,7 @@ describe('TbankClient operations', () => {
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
state: TbankOperationState.Executed,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
@@ -135,6 +137,70 @@ describe('TbankClient operations', () => {
|
||||
);
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => {
|
||||
const logger = { event: jest.fn() };
|
||||
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
|
||||
logger,
|
||||
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
|
||||
});
|
||||
const channel = { getConnectivityState: () => 1 };
|
||||
const service = {
|
||||
getChannel: () => channel,
|
||||
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => {
|
||||
const call = new EventEmitter();
|
||||
void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' }));
|
||||
return call;
|
||||
},
|
||||
};
|
||||
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
|
||||
jest.useFakeTimers();
|
||||
try {
|
||||
const failed = invoke('secret', service, 'getAccounts', {});
|
||||
await Promise.all([
|
||||
expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(1000),
|
||||
]);
|
||||
expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({
|
||||
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
|
||||
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
|
||||
}));
|
||||
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
|
||||
expect(typeof fields.networkMs).toBe('number');
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
|
||||
} finally {
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('records response headers and a validated upstream tracking ID', async () => {
|
||||
const logger = { event: jest.fn() };
|
||||
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
|
||||
logger,
|
||||
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
|
||||
});
|
||||
const headers = new Metadata();
|
||||
headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000');
|
||||
headers.set('authorization', 'Bearer secret');
|
||||
const service = {
|
||||
getChannel: () => ({ getConnectivityState: () => 2 }),
|
||||
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => {
|
||||
const call = new EventEmitter();
|
||||
void Promise.resolve().then(() => {
|
||||
call.emit('metadata', headers);
|
||||
callback(null, { accounts: [] });
|
||||
});
|
||||
return call;
|
||||
},
|
||||
};
|
||||
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
|
||||
await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] });
|
||||
expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({
|
||||
channelState: 'READY', headersReceived: true,
|
||||
trackingId: '123e4567-e89b-12d3-a456-426614174000',
|
||||
}));
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient metadata retries', () => {
|
||||
@@ -175,46 +241,30 @@ describe('TbankClient metadata retries', () => {
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
])(
|
||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
||||
'recovers metadata after one retry for transient gRPC status %s',
|
||||
async (code) => {
|
||||
requestRpc
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockRejectedValueOnce({ code })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
await jest.advanceTimersByTimeAsync(999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(3999);
|
||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
||||
it('rejects metadata immediately when the queue is full', async () => {
|
||||
queue.size = 50;
|
||||
requestRpc.mockResolvedValue({ instrument });
|
||||
|
||||
const result = client.getInstrumentByFigi('token', 'figi');
|
||||
await jest.advanceTimersByTimeAsync(1999);
|
||||
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
queue.size = 49;
|
||||
await jest.advanceTimersByTimeAsync(1);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'warn',
|
||||
'tbank.rpc.retry',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('does not bypass queue admission after an upstream failure', async () => {
|
||||
@@ -222,42 +272,22 @@ describe('TbankClient metadata retries', () => {
|
||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||
.mockResolvedValueOnce({ instrument });
|
||||
|
||||
const result = client.getInstrumentByUid('token', 'uid');
|
||||
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||
queue.size = 50;
|
||||
await jest.advanceTimersByTimeAsync(2000);
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||
queue.size = 0;
|
||||
await jest.advanceTimersByTimeAsync(4000);
|
||||
|
||||
await expect(result).resolves.toEqual(instrument);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
||||
queue.size = 50;
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
]);
|
||||
|
||||
expect(queue.run).not.toHaveBeenCalled();
|
||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
||||
expect(logger.event).toHaveBeenLastCalledWith(
|
||||
'error',
|
||||
'tbank.rpc.failed',
|
||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
||||
);
|
||||
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
||||
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||
await Promise.all([
|
||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||
jest.advanceTimersByTimeAsync(6000),
|
||||
jest.advanceTimersByTimeAsync(1000),
|
||||
]);
|
||||
|
||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
|
||||
@@ -317,3 +347,141 @@ describe('TbankClient metadata retries', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient share catalog', () => {
|
||||
const token = 'token';
|
||||
const catalogSize = 40;
|
||||
let client: TbankClient;
|
||||
let call: jest.Mock;
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
call = jest.fn();
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{ call, logger },
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||
call.mockResolvedValue({ instruments });
|
||||
|
||||
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||
const fitting = index === catalogSize - 1;
|
||||
return {
|
||||
apiTradeAvailableFlag: fitting,
|
||||
buyAvailableFlag: fitting,
|
||||
ticker: fitting ? 'FIT' : `T${index}`,
|
||||
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||
};
|
||||
});
|
||||
call.mockResolvedValue({ instruments: catalog });
|
||||
|
||||
const shares = await client.getAllShares(token);
|
||||
|
||||
expect(shares).toEqual(catalog);
|
||||
expect(shares).toHaveLength(catalogSize);
|
||||
expect(shares).toContainEqual({
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
ticker: 'FIT',
|
||||
uid: 'uid-fit',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
call.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects when the response carries no instrument list', async () => {
|
||||
call.mockResolvedValue({});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
const failure = await client
|
||||
.getAllShares(token)
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [
|
||||
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||
{ ticker: 'BROKEN' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||
});
|
||||
|
||||
await client.getAllShares(token);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'tbank.shares.catalog.fetched',
|
||||
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.shares.catalog.unusable',
|
||||
expect.objectContaining({ operation: 'shares' }),
|
||||
);
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientUnaryCall,
|
||||
connectivityState,
|
||||
loadPackageDefinition,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
@@ -29,6 +30,15 @@ import type {
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import {
|
||||
InstrumentIdType,
|
||||
TbankAccountStatus,
|
||||
TbankCandleInterval,
|
||||
TbankInstrumentStatus,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
} from './tbank-protocol';
|
||||
|
||||
const INITIAL_REQUEST_COUNT = 0;
|
||||
const MIN_RATE_DELAY_MS = 1;
|
||||
@@ -38,10 +48,8 @@ const NO_RPC_ATTEMPTS = 0;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
|
||||
const RATE_WINDOW_MS = 1_000;
|
||||
const METADATA_MAX_ATTEMPTS = 3;
|
||||
const RPC_MAX_ATTEMPTS = 2;
|
||||
const MAX_QUEUE_SIZE = 50;
|
||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
||||
const RPC_RETRY_DELAY_MS = 1_000;
|
||||
|
||||
const requestDeadlineMs = 15_000;
|
||||
@@ -53,6 +61,13 @@ type Unary<Request, Response> = (
|
||||
callback: (error: null | ServiceError, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||
type RpcDiagnostics = {
|
||||
channelState?: string;
|
||||
headersReceived?: boolean;
|
||||
networkMs?: number;
|
||||
queueWaitMs?: number;
|
||||
trackingId?: string;
|
||||
};
|
||||
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
@@ -115,7 +130,7 @@ export type TbankOperationsByCursorRequest = {
|
||||
from?: TbankTimestamp;
|
||||
instrumentId?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
state: TbankOperationState.Executed;
|
||||
to?: TbankTimestamp;
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
@@ -205,10 +220,10 @@ export class TbankClient {
|
||||
loadPackageDefinition(definition) as unknown as TbankNamespace
|
||||
).tinkoff.public.invest.api.contract.v1;
|
||||
const caPath = config.get<string | undefined>('TBANK_CA_CERT_PATH');
|
||||
const credentials = caPath ?
|
||||
const credentials = caPath
|
||||
// eslint-disable-next-line security/detect-non-literal-fs-filename -- The CA path is trusted operator configuration, never supplied by an HTTP request.
|
||||
ChannelCredentials.createSsl(readFileSync(caPath)) :
|
||||
ChannelCredentials.createSsl();
|
||||
? ChannelCredentials.createSsl(readFileSync(caPath))
|
||||
: ChannelCredentials.createSsl();
|
||||
const endpoint = config.getOrThrow<string>('TBANK_GRPC_ENDPOINT');
|
||||
const create = (name: string) =>
|
||||
new (
|
||||
@@ -256,7 +271,7 @@ export class TbankClient {
|
||||
token,
|
||||
this.users,
|
||||
'getAccounts',
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ status: TbankAccountStatus.Open },
|
||||
).then((response) => response.accounts ?? []);
|
||||
}
|
||||
|
||||
@@ -279,14 +294,14 @@ export class TbankClient {
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||
}
|
||||
|
||||
getDividends(
|
||||
@@ -314,11 +329,40 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
instrumentKind: TbankInstrumentType.Share,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
|
||||
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'shares',
|
||||
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||
);
|
||||
const { instruments } = response;
|
||||
if (
|
||||
!Array.isArray(instruments) ||
|
||||
!instruments.length ||
|
||||
instruments.some((item) => !item?.uid)
|
||||
) {
|
||||
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||
code: TbankErrorCode.Unavailable,
|
||||
operation: 'shares',
|
||||
});
|
||||
throw new ServiceUnavailableException({
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||
asOf: new Date().toISOString(),
|
||||
count: instruments.length,
|
||||
operation: 'shares',
|
||||
});
|
||||
return instruments;
|
||||
}
|
||||
|
||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
@@ -326,7 +370,7 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
instrumentKind: TbankInstrumentType.Bond,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
@@ -337,7 +381,7 @@ export class TbankClient {
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -367,7 +411,7 @@ export class TbankClient {
|
||||
this.instruments,
|
||||
'shareBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -411,12 +455,6 @@ export class TbankClient {
|
||||
).then((response) => response.lastPrices ?? []);
|
||||
}
|
||||
|
||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
||||
return this.getLastPrices(token, [uid]).then(
|
||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||
return this.call<{ tradingStatus?: string }>(
|
||||
token,
|
||||
@@ -446,7 +484,7 @@ export class TbankClient {
|
||||
to: {
|
||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||
},
|
||||
interval: 'CANDLE_INTERVAL_DAY',
|
||||
interval: TbankCandleInterval.Day,
|
||||
limit: 2400,
|
||||
},
|
||||
).then((response) => response.candles ?? []);
|
||||
@@ -454,7 +492,7 @@ export class TbankClient {
|
||||
|
||||
private async getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType,
|
||||
id: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
try {
|
||||
@@ -478,63 +516,63 @@ export class TbankClient {
|
||||
request: object,
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
const diagnostics: RpcDiagnostics = {};
|
||||
try {
|
||||
if (queueFull) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
const response = await this.queue.run(() => {
|
||||
const sentAt = performance.now();
|
||||
diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
|
||||
return this.request<Response>(
|
||||
token, client, method, request, diagnostics,
|
||||
).finally(() => {
|
||||
diagnostics.networkMs = Math.round(performance.now() - sentAt);
|
||||
});
|
||||
});
|
||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
return response;
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull ?
|
||||
{ code: 'TBANK_UNAVAILABLE' } :
|
||||
{ grpcCode };
|
||||
const retryable = queueFull ?
|
||||
metadataCall :
|
||||
[
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
status.UNAVAILABLE,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < maxAttempts) {
|
||||
const failureContext = queueFull
|
||||
? { code: TbankErrorCode.Unavailable }
|
||||
: { grpcCode };
|
||||
const retryable = !queueFull && [
|
||||
status.DEADLINE_EXCEEDED,
|
||||
status.RESOURCE_EXHAUSTED,
|
||||
status.UNAVAILABLE,
|
||||
].includes(grpcCode);
|
||||
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
...failureContext,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(
|
||||
resolve,
|
||||
metadataCall ?
|
||||
attempts * METADATA_RETRY_DELAY_MS :
|
||||
RPC_RETRY_DELAY_MS,
|
||||
),
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||
continue;
|
||||
}
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
...failureContext,
|
||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
||||
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
if (
|
||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||
) {
|
||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||
throw new Error('INVALID_TBANK_TOKEN');
|
||||
throw new Error(TbankErrorCode.InvalidToken);
|
||||
}
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
@@ -542,10 +580,10 @@ export class TbankClient {
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
}
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
|
||||
private request<Response>(
|
||||
@@ -553,17 +591,42 @@ export class TbankClient {
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
diagnostics?: RpcDiagnostics,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
metadata.set('Authorization', `Bearer ${token}`);
|
||||
// A channel state is a snapshot, not proof that this particular call reached the server.
|
||||
const channel = diagnostics && client.getChannel();
|
||||
if (diagnostics) diagnostics.headersReceived = false;
|
||||
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
|
||||
client[method](
|
||||
const call = client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||
(error, response) => (error ? reject(error) : resolve(response)),
|
||||
(error, response) => {
|
||||
if (diagnostics && channel) {
|
||||
diagnostics.channelState = connectivityState[
|
||||
channel.getConnectivityState(false)
|
||||
];
|
||||
}
|
||||
if (error) reject(error);
|
||||
else resolve(response);
|
||||
},
|
||||
);
|
||||
if (diagnostics) {
|
||||
call.on('metadata', (headers: Metadata) => {
|
||||
diagnostics.headersReceived = true;
|
||||
const trackingId = headers.get('x-tracking-id')[0];
|
||||
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
|
||||
if (
|
||||
typeof trackingId === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
|
||||
) {
|
||||
diagnostics.trackingId = trackingId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-4
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
|
||||
|
||||
import { AppModule } from './app.module';
|
||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||
import { Environment } from './config/env.validation';
|
||||
import { shouldExposeSwagger } from './config/swagger.config';
|
||||
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
|
||||
});
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
app.use(cookieParser());
|
||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
||||
app.use(requestLogging.use.bind(requestLogging));
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
forbidUnknownValues: true,
|
||||
@@ -40,7 +37,7 @@ export function configureApp(app: INestApplication): void {
|
||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
export enum AuthErrorCode {
|
||||
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
|
||||
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||
}
|
||||
@@ -8,13 +8,16 @@ import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import { Session, User } from '../../../../generated/prisma/client';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
import { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
const PASSWORD_HASH_ROUNDS = 12;
|
||||
const REFRESH_TTL_MS = 604_800_000;
|
||||
export const REFRESH_TTL_MS = 604_800_000;
|
||||
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
@@ -32,12 +35,12 @@ export class AuthService {
|
||||
async register(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
|
||||
let user: User;
|
||||
@@ -51,7 +54,7 @@ export class AuthService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -61,7 +64,7 @@ export class AuthService {
|
||||
async login(
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
@@ -70,24 +73,24 @@ export class AuthService {
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !valid) {
|
||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
|
||||
async refresh(
|
||||
token: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
let payload: RefreshPayload;
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
@@ -102,10 +105,10 @@ export class AuthService {
|
||||
if (session && !session.revokedAt) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
}
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
if (session.refreshTokenHash !== this.hash(token)) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return this.rotateSession(session, session.refreshTokenHash);
|
||||
}
|
||||
@@ -142,13 +145,13 @@ export class AuthService {
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
return user;
|
||||
}
|
||||
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
@@ -163,7 +166,7 @@ export class AuthService {
|
||||
private async rotateSession(
|
||||
session: Session & { user: User },
|
||||
expectedRefreshTokenHash?: string,
|
||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
||||
): Promise<AuthenticationResult> {
|
||||
const refreshToken = await this.jwt.signAsync(
|
||||
{
|
||||
sub: session.userId,
|
||||
@@ -183,7 +186,7 @@ export class AuthService {
|
||||
data,
|
||||
});
|
||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
(error as { code?: unknown }).code === 'P2002'
|
||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ export interface AuthUser {
|
||||
id: string;
|
||||
}
|
||||
|
||||
export type AuthenticationResult = {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: AuthUser;
|
||||
};
|
||||
|
||||
export interface AccessPayload {
|
||||
sid: string;
|
||||
sub: string;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
|
||||
validate(payload: AccessPayload): AccessPayload {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -21,9 +21,10 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
@@ -55,7 +56,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async register(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: RegisterDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
@@ -68,7 +69,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async login(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: LoginDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
@@ -84,14 +85,9 @@ export class AuthController {
|
||||
@Req() request: Request,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
try {
|
||||
const token = this.refreshToken(request);
|
||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
return this.respond(response, await this.auth.refresh(token));
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
||||
throw error;
|
||||
}
|
||||
const token = this.refreshToken(request);
|
||||
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
return this.respond(response, await this.auth.refresh(token));
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
@@ -116,11 +112,7 @@ export class AuthController {
|
||||
|
||||
private respond(
|
||||
response: Response,
|
||||
result: {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { email: string; id: string };
|
||||
},
|
||||
result: AuthenticationResult,
|
||||
) {
|
||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||
return { user: result.user, accessToken: result.accessToken };
|
||||
@@ -142,6 +134,7 @@ export class AuthController {
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env['NODE_ENV'] !== 'development',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: REFRESH_TTL_MS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MinLength,
|
||||
Matches,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
} from 'class-validator';
|
||||
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
|
||||
const MAX_EMAIL_LENGTH = 254;
|
||||
const ASCII_SPACE = 32;
|
||||
const ASCII_DELETE = 127;
|
||||
const MIN_PASSWORD_LENGTH = 12;
|
||||
// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_BYTES = 72;
|
||||
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
|
||||
|
||||
@ValidatorConstraint({ name: 'email' })
|
||||
class EmailConstraint {
|
||||
@@ -40,8 +43,9 @@ class EmailConstraint {
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||
class PasswordBytesConstraint {
|
||||
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||
@ValidatorConstraint({ name: 'passwordMaxBytes' })
|
||||
class PasswordMaxBytesConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialsDto {
|
||||
class EmailDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsString()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
@@ -62,14 +66,28 @@ export class CredentialsDto {
|
||||
)
|
||||
@Validate(EmailConstraint)
|
||||
declare email: string;
|
||||
}
|
||||
|
||||
export class RegisterDto extends EmailDto {
|
||||
@ApiProperty({
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
maxLength: MAX_PASSWORD_BYTES,
|
||||
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
|
||||
pattern: PASSWORD_PATTERN,
|
||||
example: 'a secure password',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(MIN_PASSWORD_LENGTH)
|
||||
@Validate(PasswordBytesConstraint)
|
||||
@Matches(new RegExp(PASSWORD_PATTERN), {
|
||||
message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
|
||||
})
|
||||
declare password: string;
|
||||
}
|
||||
|
||||
export class LoginDto extends EmailDto {
|
||||
@ApiProperty({
|
||||
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
|
||||
example: 'a secure password',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Validate(PasswordMaxBytesConstraint)
|
||||
declare password: string;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||
import { AuthGuard } from '@nestjs/passport';
|
||||
import { PinoLogger } from 'nestjs-pino';
|
||||
|
||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
constructor(private readonly requestContext: RequestContextService) {
|
||||
constructor(private readonly logger: PinoLogger) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||
.switchToHttp()
|
||||
.getRequest<{ user?: AccessPayload }>();
|
||||
if (request.user?.sub) {
|
||||
this.requestContext.set({ userId: request.user.sub });
|
||||
this.logger.assign({ userId: request.user.sub });
|
||||
}
|
||||
return Boolean(allowed);
|
||||
}
|
||||
|
||||
@@ -22,24 +22,23 @@ import {
|
||||
const NEXT_MONTH_OFFSET = 1;
|
||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||
|
||||
export const BankErrorCode = {
|
||||
InvalidInput: 'BANK_INVALID_INPUT',
|
||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
||||
NotFound: 'BANK_NOT_FOUND',
|
||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
||||
InvalidDate: 'BANK_INVALID_DATE',
|
||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
||||
} as const;
|
||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
||||
export enum BankErrorCode {
|
||||
CardArchived = 'BANK_CARD_ARCHIVED',
|
||||
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
|
||||
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
|
||||
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
|
||||
IntentConflict = 'BANK_INTENT_CONFLICT',
|
||||
InvalidDate = 'BANK_INVALID_DATE',
|
||||
InvalidInput = 'BANK_INVALID_INPUT',
|
||||
InvalidMoney = 'BANK_INVALID_MONEY',
|
||||
InvalidRefund = 'BANK_INVALID_REFUND',
|
||||
NotFound = 'BANK_NOT_FOUND',
|
||||
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
|
||||
OperationDeleted = 'BANK_OPERATION_DELETED',
|
||||
RefundLimit = 'BANK_REFUND_LIMIT',
|
||||
VersionConflict = 'BANK_VERSION_CONFLICT',
|
||||
WriteConflict = 'BANK_WRITE_CONFLICT',
|
||||
}
|
||||
|
||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||
throw new BadRequestException({ code });
|
||||
|
||||
@@ -103,17 +103,13 @@ export class BankCardsService {
|
||||
id,
|
||||
name: dto.name ?? previous.name,
|
||||
bankName:
|
||||
dto.bankName === undefined ?
|
||||
(previous.bankName ?? undefined) :
|
||||
(dto.bankName ?? undefined),
|
||||
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||
lastFour:
|
||||
dto.lastFour === undefined ?
|
||||
(previous.lastFour ?? undefined) :
|
||||
(dto.lastFour ?? undefined),
|
||||
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||
paymentSystem:
|
||||
dto.paymentSystem === undefined ?
|
||||
previous.paymentSystem :
|
||||
dto.paymentSystem,
|
||||
dto.paymentSystem === undefined
|
||||
? previous.paymentSystem
|
||||
: dto.paymentSystem,
|
||||
openingBalance:
|
||||
dto.openingBalance ?? rubMoney(previous.openingBalanceKopecks),
|
||||
openingDate: dto.openingDate ?? previous.openingDate,
|
||||
@@ -143,12 +139,12 @@ export class BankCardsService {
|
||||
...input,
|
||||
paymentSystem: input.paymentSystem ?? null,
|
||||
version: { increment: 1 },
|
||||
...(dto.archived === undefined ?
|
||||
{} :
|
||||
{
|
||||
archivedAt: dto.archived ?
|
||||
(previous.archivedAt ?? new Date()) :
|
||||
null,
|
||||
...(dto.archived === undefined
|
||||
? {}
|
||||
: {
|
||||
archivedAt: dto.archived
|
||||
? (previous.archivedAt ?? new Date())
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -43,29 +43,29 @@ export class BankOperationsService {
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId ?
|
||||
await this.repository.card(tx, row.userId, row.targetCardId) :
|
||||
null;
|
||||
const source = row.originalExpenseId ?
|
||||
await this.repository.operation(tx, row.userId, row.originalExpenseId) :
|
||||
null;
|
||||
const target = row.targetCardId
|
||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId ?
|
||||
await tx.expenseCategory.findFirst({
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
}) :
|
||||
null;
|
||||
})
|
||||
: null;
|
||||
const refunds =
|
||||
row.kind === BankOperationKind.Expense || source ?
|
||||
await tx.bankCardOperation.findMany({
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
}) :
|
||||
[];
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
@@ -78,12 +78,12 @@ export class BankOperationsService {
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source ?
|
||||
rubMoney(
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
||||
) :
|
||||
null,
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
@@ -91,6 +91,101 @@ export class BankOperationsService {
|
||||
};
|
||||
}
|
||||
|
||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||
const userId = rows[0]?.userId;
|
||||
if (!userId) return [];
|
||||
const cardIds = new Set(
|
||||
rows.flatMap((row) =>
|
||||
[row.cardId, row.targetCardId].filter(
|
||||
(id): id is string => id !== null,
|
||||
),
|
||||
),
|
||||
);
|
||||
const sourceIds = new Set(
|
||||
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
||||
);
|
||||
const [cards, sources] = await Promise.all([
|
||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: { userId, id: { in: [...sourceIds] } },
|
||||
}),
|
||||
]);
|
||||
const categoryIds = new Set(
|
||||
rows
|
||||
.map(
|
||||
(row) =>
|
||||
sources.find((source) => source.id === row.originalExpenseId)
|
||||
?.categoryId ?? row.categoryId,
|
||||
)
|
||||
.filter((id): id is string => id !== null),
|
||||
);
|
||||
const expenseIds = new Set(
|
||||
rows.flatMap((row) => {
|
||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||
return row.kind === BankOperationKind.Expense || source
|
||||
? [source?.id ?? row.id]
|
||||
: [];
|
||||
}),
|
||||
);
|
||||
const [categories, refunds] = await Promise.all([
|
||||
tx.expenseCategory.findMany({
|
||||
where: { userId, id: { in: [...categoryIds] } },
|
||||
}),
|
||||
tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId,
|
||||
originalExpenseId: { in: [...expenseIds] },
|
||||
deletedAt: null,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||
return rows.map((row) => {
|
||||
const card = cardsById.get(row.cardId) as { name: string };
|
||||
const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
|
||||
const source = row.originalExpenseId
|
||||
? sourcesById.get(row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const relatedRefunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? refunds.filter(
|
||||
(refund) =>
|
||||
refund.originalExpenseId === (source?.id ?? row.id) &&
|
||||
(!source || refund.id !== row.id),
|
||||
)
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
relatedRefunds.reduce(
|
||||
(sum, refund) => sum + refund.amountKopecks,
|
||||
ZERO_KOPECKS,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async validateOperation(
|
||||
tx: Tx,
|
||||
userId: string,
|
||||
@@ -336,9 +431,9 @@ export class BankOperationsService {
|
||||
query: BankOperationsQueryDto | BankPageQueryDto,
|
||||
expenses = false,
|
||||
) {
|
||||
const period = expenses ?
|
||||
undefined :
|
||||
periodBounds(query as BankOperationsQueryDto);
|
||||
const period = expenses
|
||||
? undefined
|
||||
: periodBounds(query as BankOperationsQueryDto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||
@@ -347,9 +442,9 @@ export class BankOperationsService {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
...(period ?
|
||||
{ date: { gte: period.from, lte: period.to } } :
|
||||
{ kind: BankOperationKind.Expense }),
|
||||
...(period
|
||||
? { date: { gte: period.from, lte: period.to } }
|
||||
: { kind: BankOperationKind.Expense }),
|
||||
};
|
||||
const total = await tx.bankCardOperation.count({ where });
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
@@ -359,9 +454,7 @@ export class BankOperationsService {
|
||||
take: limit,
|
||||
});
|
||||
return {
|
||||
items: await Promise.all(
|
||||
rows.map((row) => this.operationView(tx, row)),
|
||||
),
|
||||
items: await this.pageViews(tx, rows),
|
||||
total,
|
||||
page,
|
||||
hasNext: page * limit < total,
|
||||
|
||||
@@ -81,13 +81,13 @@ export function bankSummary(
|
||||
continue;
|
||||
}
|
||||
const expense =
|
||||
operation.kind === BankOperationKind.Expense ?
|
||||
operation.amountKopecks :
|
||||
ZERO_KOPECKS;
|
||||
operation.kind === BankOperationKind.Expense
|
||||
? operation.amountKopecks
|
||||
: ZERO_KOPECKS;
|
||||
const refund =
|
||||
operation.kind === BankOperationKind.Refund ?
|
||||
operation.amountKopecks :
|
||||
ZERO_KOPECKS;
|
||||
operation.kind === BankOperationKind.Refund
|
||||
? operation.amountKopecks
|
||||
: ZERO_KOPECKS;
|
||||
expenses += expense;
|
||||
refunds += refund;
|
||||
if (operation.categoryId !== null) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
BankErrorCode,
|
||||
@@ -40,19 +41,19 @@ export class BankCardsRepository {
|
||||
});
|
||||
} catch (error) {
|
||||
const code =
|
||||
error instanceof Prisma.PrismaClientKnownRequestError ?
|
||||
error.code :
|
||||
'';
|
||||
error instanceof Prisma.PrismaClientKnownRequestError
|
||||
? error.code
|
||||
: '';
|
||||
const busy =
|
||||
code === 'P2034' ||
|
||||
code === 'P2028' ||
|
||||
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
|
||||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||
(error instanceof Error &&
|
||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||
continue;
|
||||
}
|
||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
||||
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: HourlySyncStatusDto,
|
||||
nullable: true,
|
||||
description:
|
||||
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
|
||||
})
|
||||
declare sync: HourlySyncStatusDto | null;
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
@@ -1,11 +1,10 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||
list(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
||||
list(@Req() request: Request & { user: AccessPayload }) {
|
||||
return this.accounts.list(request.user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,12 @@ import { Module } from '@nestjs/common';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, PortfolioModule],
|
||||
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||
controllers: [BrokerageAccountsController],
|
||||
providers: [BrokerageAccountsService],
|
||||
exports: [BrokerageAccountsService],
|
||||
|
||||
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
|
||||
|
||||
describe('saved brokerage account projection', () => {
|
||||
const portfolio = { saved: jest.fn() };
|
||||
const service = new BrokerageAccountsService(portfolio as never);
|
||||
const sync = { hourly: jest.fn().mockResolvedValue(null) };
|
||||
const service = new BrokerageAccountsService(portfolio as never, sync as never);
|
||||
|
||||
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: { ...meta, availability: 'missing' },
|
||||
membership: { ...meta, completeness: 'incomplete' },
|
||||
accounts: [],
|
||||
});
|
||||
expect((await service.list('owner', true)).data).toBeNull();
|
||||
expect((await service.list('owner')).data).toBeNull();
|
||||
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
||||
expect((await service.list('owner', true)).data).toEqual([]);
|
||||
expect((await service.list('owner')).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||
@@ -58,13 +59,46 @@ describe('saved brokerage account projection', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await service.list('owner', true);
|
||||
const response = await service.list('owner');
|
||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 1,
|
||||
});
|
||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(response.meta.availability).toBe('partial');
|
||||
expect(response.meta.availability).toBe('available');
|
||||
expect(response.meta.completeness).toBe('complete');
|
||||
});
|
||||
|
||||
it('observes the latest hourly walk without starting another one', async () => {
|
||||
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
||||
sync.hourly.mockResolvedValue({
|
||||
startedAt: new Date('2026-09-25T12:00:00Z'),
|
||||
finishedAt: new Date('2026-09-25T12:01:00Z'),
|
||||
state: 'partial',
|
||||
sections: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await service.list('owner');
|
||||
|
||||
expect(response.sync).toEqual({
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
state: 'partial',
|
||||
problems: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sync.hourly).toHaveBeenCalledWith('owner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,24 +3,28 @@ import { createHash } from 'node:crypto';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
|
||||
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageAccountsService {
|
||||
constructor(private readonly portfolio: PortfolioService) {}
|
||||
constructor(
|
||||
private readonly portfolio: PortfolioService,
|
||||
private readonly sync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
async list(userId: string, observe = false) {
|
||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
|
||||
const saved = await this.portfolio.saved(userId, 'all');
|
||||
const data: BrokerageAccountDto[] | null =
|
||||
saved.membership.availability === 'missing' ?
|
||||
null :
|
||||
saved.accounts.map((account) => ({
|
||||
saved.membership.completeness === 'incomplete'
|
||||
? null
|
||||
: saved.accounts.map((account) => ({
|
||||
id: account.id,
|
||||
name: account.name,
|
||||
type: account.type as BrokerageAccountDto['type'],
|
||||
openedOn: account.openedOn,
|
||||
portfolioSummary: account.snapshot ?
|
||||
{
|
||||
portfolioSummary: account.snapshot
|
||||
? {
|
||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||
stockPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'STOCK',
|
||||
@@ -28,9 +32,11 @@ export class BrokerageAccountsService {
|
||||
bondPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'BOND',
|
||||
).length,
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
// The read-only result of the last hourly walk; never starts one.
|
||||
const hourly = await this.sync.hourly(userId);
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
@@ -47,20 +53,28 @@ export class BrokerageAccountsService {
|
||||
)
|
||||
.digest('base64url'),
|
||||
availability:
|
||||
data === null ?
|
||||
'missing' :
|
||||
saved.accounts.some((account) => !account.snapshot) ?
|
||||
'partial' :
|
||||
'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt ?
|
||||
new Date(saved.membership.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: 300_000,
|
||||
work: saved.membership.sync.id ?
|
||||
{ ...saved.membership.sync, id: saved.membership.sync.id } :
|
||||
null,
|
||||
data === null
|
||||
? 'missing'
|
||||
: 'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt
|
||||
? new Date(saved.membership.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: 3_600_000,
|
||||
work: null,
|
||||
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
|
||||
dependencies: saved.accounts.map((account) => account.meta),
|
||||
}),
|
||||
sync: hourly
|
||||
? {
|
||||
state: hourly.state,
|
||||
startedAt: hourly.startedAt.toISOString(),
|
||||
finishedAt: hourly.finishedAt?.toISOString() ?? null,
|
||||
problems: hourly.sections.map((section) => ({
|
||||
...section,
|
||||
accountId: section.accountId ?? null,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,28 +67,6 @@ export class IncomeSummaryDto {
|
||||
declare calculatedAt: string;
|
||||
}
|
||||
|
||||
export class IncomeComparisonCurrencyDto {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||
declare selected: IncomeCurrencySummaryDto;
|
||||
|
||||
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||
declare compared: IncomeCurrencySummaryDto;
|
||||
|
||||
@ApiProperty({ type: IncomeCurrencySummaryDto })
|
||||
declare difference: IncomeCurrencySummaryDto;
|
||||
}
|
||||
|
||||
export class IncomeComparisonDto {
|
||||
@ApiProperty({ type: IncomePeriodDto })
|
||||
declare period: IncomePeriodDto;
|
||||
|
||||
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
|
||||
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
|
||||
}
|
||||
|
||||
export class IncomeBucketDto {
|
||||
@ApiProperty({ type: IncomePeriodDto })
|
||||
declare period: IncomePeriodDto;
|
||||
@@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
||||
}
|
||||
|
||||
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||
declare previous: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
|
||||
declare yearComparison: IncomeComparisonDto | null;
|
||||
|
||||
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||
|
||||
@@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto {
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
||||
const incomeDependencyRoles = ['selected'] as const;
|
||||
|
||||
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||
@ApiProperty({ enum: incomeDependencyRoles })
|
||||
|
||||
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('labels each comparison dependency independently of its resource key', async () => {
|
||||
it('labels the selected dependency independently of its resource key', async () => {
|
||||
seed([]);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
|
||||
);
|
||||
expect(response.meta.dependencies).toEqual([
|
||||
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
||||
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('labels an unavailable leap-day comparison as year', async () => {
|
||||
operations.collectRanges.mockImplementation(
|
||||
(_user: string, _account: string, build: () => unknown[]) =>
|
||||
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
|
||||
);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
'account',
|
||||
'2024-02-29',
|
||||
'2024-02-29',
|
||||
);
|
||||
expect(response.meta.dependencies).toEqual([
|
||||
expect.objectContaining({ role: 'selected' }),
|
||||
expect.objectContaining({ role: 'previous' }),
|
||||
expect.objectContaining({
|
||||
role: 'year',
|
||||
availability: 'missing',
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
}),
|
||||
]);
|
||||
expect(response.data?.yearComparison).toBeNull();
|
||||
expect(
|
||||
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
function seed(items: BrokerageOperationDto[]) {
|
||||
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
build: (opened?: Date) => unknown[],
|
||||
) => {
|
||||
build(undefined);
|
||||
return [items, [], []].map((items, index) => ({
|
||||
return [items].map((items, index) => ({
|
||||
items,
|
||||
meta: completeMeta(index),
|
||||
}));
|
||||
@@ -189,6 +167,49 @@ describe('IncomeAnalyticsService', () => {
|
||||
).toEqual(['10.25', '-0.25']);
|
||||
});
|
||||
|
||||
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
|
||||
seed([
|
||||
{
|
||||
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
|
||||
occurredAt: '2026-01-14T20:59:59.999Z',
|
||||
},
|
||||
{
|
||||
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
|
||||
occurredAt: '2026-01-14T21:00:00.000Z',
|
||||
},
|
||||
{
|
||||
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
|
||||
occurredAt: '2026-01-15T20:59:59.999Z',
|
||||
},
|
||||
]);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
'account',
|
||||
'2026-01-14',
|
||||
'2026-01-16',
|
||||
);
|
||||
expect(response.data?.buckets).toEqual([
|
||||
{
|
||||
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||
totalsByCurrency: [expect.objectContaining({
|
||||
total: { currency: 'RUB', value: '0.1' },
|
||||
})],
|
||||
},
|
||||
{
|
||||
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||
totalsByCurrency: [expect.objectContaining({
|
||||
coupon: { currency: 'RUB', value: '0.2' },
|
||||
dividend: { currency: 'RUB', value: '-0.1' },
|
||||
total: { currency: 'RUB', value: '0.1' },
|
||||
})],
|
||||
},
|
||||
{
|
||||
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||
totalsByCurrency: [],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||
operations.collectRanges.mockImplementation(
|
||||
(
|
||||
@@ -204,8 +225,6 @@ describe('IncomeAnalyticsService', () => {
|
||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||
],
|
||||
[],
|
||||
[],
|
||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,8 +6,8 @@ import {
|
||||
compare,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeComparisonDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
@@ -46,14 +45,11 @@ export class IncomeAnalyticsService {
|
||||
accountId: string,
|
||||
observe = false,
|
||||
): Promise<IncomeSummaryResourceDto> {
|
||||
let plan = planIncomePeriod(undefined, undefined);
|
||||
const plan = planIncomePeriod(undefined, undefined);
|
||||
const [selected] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(undefined, undefined, openedAt);
|
||||
return [plan.selected];
|
||||
},
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||
@@ -76,54 +72,33 @@ export class IncomeAnalyticsService {
|
||||
to: string | undefined,
|
||||
observe = false,
|
||||
): Promise<IncomeAnalyticsResourceDto> {
|
||||
let plan = planIncomePeriod(from, to);
|
||||
const periods = await this.operations.collectRanges(
|
||||
const plan = planIncomePeriod(from, to);
|
||||
const [current] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(from, to, openedAt);
|
||||
return [
|
||||
plan.selected,
|
||||
plan.previous,
|
||||
...(plan.yearAgo ? [plan.yearAgo] : []),
|
||||
];
|
||||
},
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
const [current, prior, year] = periods;
|
||||
// Preserve the legacy order while consumers migrate to explicit roles.
|
||||
const dependencies: IncomeDependencyDto[] = [
|
||||
{ ...current.meta, role: 'selected' },
|
||||
{ ...prior.meta, role: 'previous' },
|
||||
{
|
||||
...(plan.yearAgo && year ?
|
||||
year.meta :
|
||||
investmentResourceMeta({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
dataRevision: 'unavailable',
|
||||
availability: 'missing',
|
||||
fetchedAt: null,
|
||||
freshForMs: 0,
|
||||
work: null,
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
})),
|
||||
role: 'year',
|
||||
},
|
||||
];
|
||||
const states = periods.map((period) => period.meta.sync);
|
||||
const work =
|
||||
states.find((state) => state.state === 'running') ??
|
||||
states.find((state) => state.state === 'failed');
|
||||
const work = current.meta.sync;
|
||||
const meta = {
|
||||
...investmentResourceMeta({
|
||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||
dataRevision: current.meta.dataRevision,
|
||||
availability: current.meta.availability,
|
||||
fetchedAt: current.meta.lastSuccessfulAt ?
|
||||
new Date(current.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
availability:
|
||||
current.items === null
|
||||
? 'temporarily-unavailable'
|
||||
: 'available',
|
||||
fetchedAt: current.meta.lastSuccessfulAt
|
||||
? new Date(current.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
work: work.id
|
||||
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
||||
: null,
|
||||
lastAttempt: current.meta.lastAttempt,
|
||||
coverage: current.meta.coverage,
|
||||
dependencies,
|
||||
}),
|
||||
@@ -143,14 +118,6 @@ export class IncomeAnalyticsService {
|
||||
selected,
|
||||
current.meta.lastSuccessfulAt,
|
||||
),
|
||||
previous:
|
||||
prior.items === null ?
|
||||
null :
|
||||
comparison(selected, plan.previous, qualifying(prior.items)),
|
||||
yearComparison:
|
||||
plan.yearAgo && year?.items !== null && year ?
|
||||
comparison(selected, plan.yearAgo, qualifying(year.items)) :
|
||||
null,
|
||||
bucketGranularity: plan.granularity,
|
||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||
instruments: instruments(selected),
|
||||
@@ -165,7 +132,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
Boolean(entry.payment) &&
|
||||
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type),
|
||||
[TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -184,7 +151,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
if (!entry.payment) continue;
|
||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||
const value = decimalFromString(entry.payment.value);
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
||||
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||
total.coupon = add(total.coupon, value);
|
||||
} else total.dividend = add(total.dividend, value);
|
||||
values.set(entry.payment.currency, total);
|
||||
@@ -194,50 +161,21 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
.map(([currency, total]) => presentTotal(currency, total));
|
||||
}
|
||||
|
||||
function comparison(
|
||||
selectedEntries: BrokerageOperationDto[],
|
||||
comparedPeriod: DateOnlyRange,
|
||||
comparedEntries: BrokerageOperationDto[],
|
||||
): IncomeComparisonDto {
|
||||
const selected = new Map(
|
||||
totals(selectedEntries).map((total) => [total.currency, total]),
|
||||
);
|
||||
const compared = new Map(
|
||||
totals(comparedEntries).map((total) => [total.currency, total]),
|
||||
);
|
||||
const currencies = [
|
||||
...new Set([...selected.keys(), ...compared.keys()]),
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
].sort();
|
||||
return {
|
||||
period: comparedPeriod,
|
||||
totalsByCurrency: currencies.map((currency) => {
|
||||
const current =
|
||||
selected.get(currency) ?? presentTotal(currency, zeroTotal());
|
||||
const prior =
|
||||
compared.get(currency) ?? presentTotal(currency, zeroTotal());
|
||||
return {
|
||||
currency,
|
||||
selected: current,
|
||||
compared: prior,
|
||||
difference: difference(current, prior),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function buckets(
|
||||
range: DateOnlyRange,
|
||||
granularity: IncomeBucketGranularity,
|
||||
entries: BrokerageOperationDto[],
|
||||
) {
|
||||
const datedEntries = entries.map((entry) => ({
|
||||
entry,
|
||||
date: moscowDate(new Date(entry.occurredAt)),
|
||||
}));
|
||||
return rangeBuckets(range, granularity).map((period) => ({
|
||||
period,
|
||||
totalsByCurrency: totals(
|
||||
entries.filter((entry) => {
|
||||
const date = moscowDate(new Date(entry.occurredAt));
|
||||
return date >= period.from && date <= period.to;
|
||||
}),
|
||||
datedEntries
|
||||
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||
.map(({ entry }) => entry),
|
||||
),
|
||||
}));
|
||||
}
|
||||
@@ -286,22 +224,6 @@ function instrumentKey(entry: BrokerageOperationDto): string {
|
||||
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
||||
}
|
||||
|
||||
function difference(
|
||||
left: IncomeCurrencySummaryDto,
|
||||
right: IncomeCurrencySummaryDto,
|
||||
): IncomeCurrencySummaryDto {
|
||||
return presentTotal(left.currency, {
|
||||
coupon: subtract(
|
||||
decimalFromString(left.coupon.value),
|
||||
decimalFromString(right.coupon.value),
|
||||
),
|
||||
dividend: subtract(
|
||||
decimalFromString(left.dividend.value),
|
||||
decimalFromString(right.dividend.value),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
function zeroTotal(): Total {
|
||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
||||
}
|
||||
|
||||
@@ -23,16 +23,14 @@ describe('income period planning', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
|
||||
expect(fullMonth.previous).toEqual({
|
||||
from: '2026-02-01',
|
||||
to: '2026-02-28',
|
||||
});
|
||||
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
|
||||
it('keeps empty buckets and skips removed comparison ranges', () => {
|
||||
expect(
|
||||
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
||||
).toHaveLength(7);
|
||||
expect(planIncomePeriod('2026-03-01', '2026-03-31')).toEqual({
|
||||
selected: { from: '2026-03-01', to: '2026-03-31' },
|
||||
granularity: 'day',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
|
||||
@@ -43,7 +41,6 @@ describe('income period planning', () => {
|
||||
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
|
||||
expect(plan.granularity).toBe('month');
|
||||
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
|
||||
expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' });
|
||||
});
|
||||
|
||||
it('does not send the unfinished Moscow day as a future instant', () => {
|
||||
|
||||
@@ -8,55 +8,40 @@ import {
|
||||
utcDate,
|
||||
} from '../investment-calendar';
|
||||
|
||||
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||
|
||||
const MONDAY_WEEKDAY_INDEX = 1;
|
||||
const INCLUSIVE_DAY_OFFSET = 1;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||
const PREVIOUS_DAY_OFFSET = -1;
|
||||
const PREVIOUS_YEAR_OFFSET = 1;
|
||||
const DATE_COMPONENT_DIGITS = 2;
|
||||
|
||||
const DAILY_BUCKET_MAX_DAYS = 45;
|
||||
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
|
||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
||||
|
||||
export function planIncomePeriod(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
openedDate?: Date,
|
||||
): {
|
||||
granularity: IncomeBucketGranularity;
|
||||
previous: DateOnlyRange;
|
||||
selected: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
} {
|
||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||
const today = moscowDate(new Date());
|
||||
const selected =
|
||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||
assertRange(selected, today);
|
||||
const previous = isFullMonth(selected) ?
|
||||
previousMonth(selected.from) :
|
||||
shiftDays(selected, -rangeDays(selected));
|
||||
const candidate = yearAgo(selected);
|
||||
const yearAgoRange =
|
||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate)) ?
|
||||
candidate :
|
||||
null;
|
||||
return {
|
||||
selected,
|
||||
previous,
|
||||
yearAgo: yearAgoRange,
|
||||
granularity:
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS ?
|
||||
'day' :
|
||||
rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS ?
|
||||
'week' :
|
||||
'month',
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||
? 'day'
|
||||
: rangeDays(selected) <= WEEKLY_BUCKET_MAX_DAYS
|
||||
? 'week'
|
||||
: 'month',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -69,20 +54,20 @@ export function rangeBuckets(
|
||||
}
|
||||
const buckets: DateOnlyRange[] = [];
|
||||
let cursor =
|
||||
granularity === 'week' ?
|
||||
addDays(
|
||||
granularity === 'week'
|
||||
? addDays(
|
||||
range.from,
|
||||
-(
|
||||
(utcDate(range.from).getUTCDay() + DAYS_PER_WEEK - MONDAY_WEEKDAY_INDEX) %
|
||||
DAYS_PER_WEEK
|
||||
),
|
||||
) :
|
||||
`${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
||||
)
|
||||
: `${range.from.slice(ISO_DATE_START, ISO_MONTH_LENGTH)}-01`;
|
||||
while (cursor <= range.to) {
|
||||
const end =
|
||||
granularity === 'week' ?
|
||||
addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET) :
|
||||
endOfMonth(cursor);
|
||||
granularity === 'week'
|
||||
? addDays(cursor, DAYS_PER_WEEK - INCLUSIVE_DAY_OFFSET)
|
||||
: endOfMonth(cursor);
|
||||
buckets.push({
|
||||
from: cursor < range.from ? range.from : cursor,
|
||||
to: end > range.to ? range.to : end,
|
||||
@@ -134,34 +119,6 @@ function endOfMonth(value: string): string {
|
||||
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
}
|
||||
|
||||
function isFullMonth(range: DateOnlyRange): boolean {
|
||||
return (
|
||||
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
|
||||
);
|
||||
}
|
||||
|
||||
function previousMonth(from: string): DateOnlyRange {
|
||||
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
|
||||
return { from: firstOfMonth(last), to: last };
|
||||
}
|
||||
|
||||
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
|
||||
return { from: addDays(range.from, days), to: addDays(range.to, days) };
|
||||
}
|
||||
|
||||
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||
const shift = (value: string) => {
|
||||
const [year, month, day] = dateParts(value);
|
||||
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
|
||||
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result ?
|
||||
result :
|
||||
null;
|
||||
};
|
||||
const from = shift(range.from);
|
||||
const to = shift(range.to);
|
||||
return from && to ? { from, to } : null;
|
||||
}
|
||||
|
||||
function invalidPeriod(): BadRequestException {
|
||||
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
|
||||
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
||||
}
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
||||
@@ -5,10 +5,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
@@ -16,21 +15,21 @@ import {
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
||||
import {
|
||||
candleGaps,
|
||||
type CandleRange,
|
||||
candleWindows,
|
||||
utcDay,
|
||||
} from '../domain/candle-coverage';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
||||
const REPLACED_WINDOW_COUNT = 1;
|
||||
const NEXT_WINDOW_OFFSET = 1;
|
||||
const NO_CANDLE_GAPS = 0;
|
||||
|
||||
type CandleInput = {
|
||||
instrumentType: 'bond' | 'share';
|
||||
@@ -54,7 +53,6 @@ type Producer = {
|
||||
};
|
||||
const months = { month: 1, halfYear: 6, year: 12 };
|
||||
const tailFreshForMs = 60_000;
|
||||
const prefetchReadBatchSize = 100;
|
||||
function scope(input: CandleInput) {
|
||||
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||
}
|
||||
@@ -70,9 +68,9 @@ function rangeFor(
|
||||
if (!history) {
|
||||
throw new ServiceUnavailableException({
|
||||
code:
|
||||
input.instrumentType === 'share' ?
|
||||
'STOCK_HISTORY_START_UNAVAILABLE' :
|
||||
'BOND_HISTORY_START_UNAVAILABLE',
|
||||
input.instrumentType === 'share'
|
||||
? InstrumentErrorCode.StockHistoryStartUnavailable
|
||||
: InstrumentErrorCode.BondHistoryStartUnavailable,
|
||||
});
|
||||
}
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
@@ -92,7 +90,6 @@ export class CandleService {
|
||||
private readonly queue: Producer[] = [];
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
private running = false;
|
||||
private readonly prefetches = new Map<string, Promise<void>>();
|
||||
constructor(
|
||||
private readonly repository: CandleRepository,
|
||||
private readonly sources: CatalogEnrichmentService,
|
||||
@@ -110,16 +107,18 @@ export class CandleService {
|
||||
true,
|
||||
['detail'],
|
||||
);
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
||||
if (detail.meta.completeness === 'complete' && !detail.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const now = new Date();
|
||||
const range = detail.instrument ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
const range = detail.instrument
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||
if (!observe && (!saved || gaps.length)) {
|
||||
const hasCompleteRange = Boolean(
|
||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||
);
|
||||
if (!observe && (!saved || !hasCompleteRange)) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
@@ -139,14 +138,14 @@ export class CandleService {
|
||||
});
|
||||
return {
|
||||
data:
|
||||
!saved || !covered ?
|
||||
null :
|
||||
{
|
||||
!saved || !covered
|
||||
? null
|
||||
: {
|
||||
candles: saved.data,
|
||||
currency:
|
||||
input.instrumentType === 'share' ?
|
||||
detail.instrument?.currency?.toUpperCase() || null :
|
||||
null,
|
||||
input.instrumentType === 'share'
|
||||
? detail.instrument?.currency?.toUpperCase() || null
|
||||
: null,
|
||||
historyFrom: timestamp(detail?.instrument?.first_1dayCandleDate),
|
||||
observedAt: fetchedAt?.toISOString() ?? now.toISOString(),
|
||||
},
|
||||
@@ -159,8 +158,8 @@ export class CandleService {
|
||||
work,
|
||||
coverage: {
|
||||
requested: range ? [publicRange(range)] : [],
|
||||
completeRanges: range ?
|
||||
completeCoverage.flatMap((row) => {
|
||||
completeRanges: range
|
||||
? completeCoverage.flatMap((row) => {
|
||||
const from = new Date(
|
||||
Math.max(row.from.valueOf(), range.from.valueOf()),
|
||||
);
|
||||
@@ -168,22 +167,22 @@ export class CandleService {
|
||||
Math.min(row.to.valueOf(), range.to.valueOf()),
|
||||
);
|
||||
return from < to ? [publicRange({ from, to })] : [];
|
||||
}) :
|
||||
[],
|
||||
})
|
||||
: [],
|
||||
missingRanges: missing.map(publicRange),
|
||||
asOf: fetchedAt?.toISOString() ?? null,
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate) ?
|
||||
'known' :
|
||||
'unknown',
|
||||
historyState: complete ?
|
||||
'complete' :
|
||||
work?.state === 'failed' ?
|
||||
'failed' :
|
||||
work?.state === 'running' ?
|
||||
'loading' :
|
||||
covered ?
|
||||
'partial' :
|
||||
'missing',
|
||||
historyBoundary: timestamp(detail?.instrument?.first_1dayCandleDate)
|
||||
? 'known'
|
||||
: 'unknown',
|
||||
historyState: complete
|
||||
? 'complete'
|
||||
: work?.state === 'failed'
|
||||
? 'failed'
|
||||
: work?.state === 'running'
|
||||
? 'loading'
|
||||
: covered
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
},
|
||||
dependencies: [
|
||||
detail.meta,
|
||||
@@ -193,9 +192,9 @@ export class CandleService {
|
||||
dataRevision: String(row.dataRevision),
|
||||
availability: 'complete',
|
||||
fetchedAt: row.fetchedAt,
|
||||
freshForMs: row.isComplete ?
|
||||
Number.POSITIVE_INFINITY :
|
||||
tailFreshForMs,
|
||||
freshForMs: row.isComplete
|
||||
? Number.POSITIVE_INFINITY
|
||||
: tailFreshForMs,
|
||||
work: null,
|
||||
}),
|
||||
),
|
||||
@@ -204,110 +203,6 @@ export class CandleService {
|
||||
};
|
||||
}
|
||||
|
||||
prefetch(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const key = JSON.stringify([
|
||||
userId,
|
||||
revision,
|
||||
positions
|
||||
.map((position) => [position.instrumentUid, position.type])
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
.sort(),
|
||||
]);
|
||||
const existing = this.prefetches.get(key);
|
||||
if (existing) return existing;
|
||||
const work = this.prefetchPositions(userId, revision, positions).finally(
|
||||
() => this.prefetches.delete(key),
|
||||
);
|
||||
this.prefetches.set(key, work);
|
||||
return work;
|
||||
}
|
||||
|
||||
private async prefetchPositions(
|
||||
userId: string,
|
||||
revision: string,
|
||||
positions: { instrumentUid?: null | string; type: string }[],
|
||||
) {
|
||||
const now = new Date();
|
||||
const unique = new Map(
|
||||
positions
|
||||
.filter(
|
||||
(position) =>
|
||||
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
|
||||
)
|
||||
.map((position) => [position.instrumentUid ?? '', position]),
|
||||
);
|
||||
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
|
||||
[];
|
||||
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
|
||||
for (const instrumentType of ['share', 'bond'] as const) {
|
||||
const uids = [...unique]
|
||||
.filter(
|
||||
([, position]) =>
|
||||
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
|
||||
)
|
||||
.map(([uid]) => uid);
|
||||
for (
|
||||
let offset = FIRST_BATCH_OFFSET;
|
||||
offset < uids.length;
|
||||
offset += prefetchReadBatchSize
|
||||
) {
|
||||
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
|
||||
details.push(
|
||||
...(await this.sources.readMany(userId, instrumentType, batch, true, [
|
||||
'detail',
|
||||
])),
|
||||
);
|
||||
coverage.push(...(await this.repository.coverage(batch)));
|
||||
}
|
||||
}
|
||||
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
|
||||
for (const [uid, position] of unique) {
|
||||
const instrumentType: CandleInput['instrumentType'] =
|
||||
position.type === 'STOCK' ? 'share' : 'bond';
|
||||
const detail = byUid.get(uid);
|
||||
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
|
||||
const plan = planInvestmentScreen(
|
||||
{ screen: 'account' },
|
||||
{
|
||||
membershipKnown: true,
|
||||
accounts: [],
|
||||
instruments: [
|
||||
{
|
||||
uid,
|
||||
type: position.type as 'BOND' | 'STOCK',
|
||||
historyFrom: history ? new Date(history) : null,
|
||||
},
|
||||
],
|
||||
},
|
||||
now,
|
||||
);
|
||||
if (
|
||||
!detail?.instrument ||
|
||||
plan.sources.some((source) => source.kind === 'candles')
|
||||
) {
|
||||
const input = { uid, instrumentType, period: 'all' as const };
|
||||
const range =
|
||||
detail?.instrument && history ?
|
||||
rangeFor(input, detail.instrument, now) :
|
||||
null;
|
||||
if (
|
||||
!range ||
|
||||
candleGaps(
|
||||
range,
|
||||
coverage.filter((row) => row.instrumentUid === uid),
|
||||
now,
|
||||
).length
|
||||
) {
|
||||
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
@@ -411,17 +306,7 @@ export class CandleService {
|
||||
window.from,
|
||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||
);
|
||||
const rows = candles.flatMap((candle) => {
|
||||
const time = timestamp(candle.time);
|
||||
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||
return [
|
||||
{
|
||||
time,
|
||||
close: formatDecimal(decimal(candle.close)),
|
||||
isComplete: candle.isComplete === true,
|
||||
},
|
||||
];
|
||||
});
|
||||
const rows = candles.map(toSavedCandle);
|
||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||
return false;
|
||||
}
|
||||
@@ -448,7 +333,7 @@ export class CandleService {
|
||||
const producer = this.queue.shift();
|
||||
if (!producer) break;
|
||||
let success = false;
|
||||
let code = 'TBANK_UNAVAILABLE';
|
||||
let code: string = TbankErrorCode.Unavailable;
|
||||
try {
|
||||
const result = await this.load(producer);
|
||||
if (result === null) {
|
||||
@@ -457,7 +342,7 @@ export class CandleService {
|
||||
}
|
||||
success = result;
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
await this.access.rejectMembershipToken(producer.attempt);
|
||||
}
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
@@ -508,22 +393,11 @@ export class CandleService {
|
||||
this.reservations = finished.catch(() => undefined);
|
||||
if (!(await finished)) {
|
||||
this.queue.push(producer);
|
||||
continue;
|
||||
}
|
||||
if (success && !producer.force && producer.input.period !== 'all') {
|
||||
await this.request(
|
||||
producer.attempt.userId,
|
||||
producer.attempt.revision,
|
||||
{ ...producer.input, period: 'all' },
|
||||
producer.now,
|
||||
false,
|
||||
BACKGROUND_SYNC_PRIORITY,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'candles.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
} finally {
|
||||
this.active.clear();
|
||||
|
||||
@@ -3,17 +3,14 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
@@ -25,6 +22,7 @@ import {
|
||||
catalogSearchLimit,
|
||||
selectCatalogInstruments,
|
||||
} from './catalog-selection';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||
const NO_MATCHES = 0;
|
||||
@@ -32,38 +30,19 @@ const FIRST_RESULT_OFFSET = 0;
|
||||
|
||||
const tickerAmbiguityLimit = 2;
|
||||
|
||||
type Producer = {
|
||||
attempt: TbankSyncAttempt;
|
||||
force: boolean;
|
||||
input: CatalogQuery;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@Injectable()
|
||||
export class CatalogDiscoveryService {
|
||||
private readonly runId = randomUUID();
|
||||
private readonly active = new Set<string>();
|
||||
private readonly producers = new Map<string, Producer>();
|
||||
private readonly queue: Producer[] = [];
|
||||
private reservations: Promise<unknown> = Promise.resolve();
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly enrichment: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CatalogQuery, observe = false) {
|
||||
async read(userId: string, input: CatalogQuery) {
|
||||
input = catalogQuery(input);
|
||||
const connection = await this.access.local(userId);
|
||||
const saved = await this.repository.read(input);
|
||||
if (!observe) await this.request(userId, connection.revision, input);
|
||||
return {
|
||||
data: saved.data,
|
||||
meta: investmentResourceMeta({
|
||||
@@ -71,22 +50,23 @@ export class CatalogDiscoveryService {
|
||||
dataRevision: saved.dataRevision,
|
||||
availability: saved.data === null ? 'missing' : 'complete',
|
||||
fetchedAt: saved.fetchedAt,
|
||||
freshForMs: tbankSourceTtlMs.catalog,
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: await this.repository.work(userId, connection.revision, input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||
const discovery = await this.read(userId, input, observe);
|
||||
const sources = discovery.data ?
|
||||
await this.enrichment.readMany(
|
||||
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
||||
const discovery = await this.read(userId, input);
|
||||
const sources = discovery.data
|
||||
? await this.enrichment.readMany(
|
||||
userId,
|
||||
input.instrumentType,
|
||||
discovery.data.map((item) => item.uid),
|
||||
observe,
|
||||
) :
|
||||
[];
|
||||
)
|
||||
: [];
|
||||
const dependencies = [
|
||||
discovery.meta,
|
||||
...sources.map((source) => source.meta),
|
||||
@@ -114,22 +94,22 @@ export class CatalogDiscoveryService {
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability:
|
||||
discovery.data === null ?
|
||||
'missing' :
|
||||
sources.every((source) => source.meta.availability === 'complete') ?
|
||||
'complete' :
|
||||
'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt ?
|
||||
new Date(discovery.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.catalog,
|
||||
work: discovery.meta.sync.id ?
|
||||
{
|
||||
discovery.data === null
|
||||
? 'missing'
|
||||
: sources.every((source) => source.meta.completeness === 'complete')
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||
? new Date(discovery.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: discovery.meta.sync.id
|
||||
? {
|
||||
id: discovery.meta.sync.id,
|
||||
state: discovery.meta.sync.state,
|
||||
errorCode: discovery.meta.sync.errorCode,
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
@@ -141,6 +121,25 @@ export class CatalogDiscoveryService {
|
||||
uid: string,
|
||||
observe = false,
|
||||
) {
|
||||
if (!observe) {
|
||||
const instrument = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.getShare(token, uid)
|
||||
: this.tbank.getBond(token, uid),
|
||||
);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
return {
|
||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
@@ -149,7 +148,7 @@ export class CatalogDiscoveryService {
|
||||
['detail'],
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.availability === 'complete' && !source.instrument) {
|
||||
if (source.meta.completeness === 'complete' && !source.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const instrument = source.instrument;
|
||||
@@ -159,11 +158,11 @@ export class CatalogDiscoveryService {
|
||||
instrument?.ticker &&
|
||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
||||
) {
|
||||
const alias = await this.read(
|
||||
userId,
|
||||
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
||||
observe,
|
||||
);
|
||||
const alias = await this.read(userId, {
|
||||
instrumentType,
|
||||
query: instrument.ticker,
|
||||
exactTicker: true,
|
||||
});
|
||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
||||
slug = instrument.ticker.toUpperCase();
|
||||
}
|
||||
@@ -177,228 +176,143 @@ export class CatalogDiscoveryService {
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: source.meta.availability,
|
||||
fetchedAt: source.meta.lastSuccessfulAt ?
|
||||
new Date(source.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: source.meta.sync.id ?
|
||||
{
|
||||
fetchedAt: source.meta.lastSuccessfulAt
|
||||
? new Date(source.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: source.meta.sync.id
|
||||
? {
|
||||
id: source.meta.sync.id,
|
||||
state: source.meta.sync.state,
|
||||
errorCode: source.meta.sync.errorCode,
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
||||
async byTicker(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
ticker: string,
|
||||
observe = false,
|
||||
) {
|
||||
const lookup = await this.read(
|
||||
userId,
|
||||
{ instrumentType, query: ticker, exactTicker: true },
|
||||
observe,
|
||||
);
|
||||
if (!observe) {
|
||||
const query = ticker.trim().toUpperCase();
|
||||
const found = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.findShares(token, query)
|
||||
: this.tbank.findBonds(token, query),
|
||||
);
|
||||
const matches = selectCatalogInstruments(
|
||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||
query,
|
||||
instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||
if (!matches.length) throw new NotFoundException();
|
||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
? InstrumentErrorCode.StockTickerAmbiguous
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||
}
|
||||
const lookup = await this.read(userId, {
|
||||
instrumentType,
|
||||
query: ticker,
|
||||
exactTicker: true,
|
||||
});
|
||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share' ?
|
||||
'STOCK_TICKER_AMBIGUOUS' :
|
||||
'BOND_TICKER_AMBIGUOUS',
|
||||
instrumentType === 'share'
|
||||
? InstrumentErrorCode.StockTickerAmbiguous
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = lookup.data ?
|
||||
await this.detail(userId, instrumentType, lookup.data[0].uid, observe) :
|
||||
null;
|
||||
const result = lookup.data
|
||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||
: null;
|
||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||
return {
|
||||
data: result?.data ?
|
||||
{ ...result.data, slug: ticker.trim().toUpperCase() } :
|
||||
null,
|
||||
data: result?.data
|
||||
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||
: null,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
||||
dataRevision: dependencies
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: result?.meta.availability ?? 'missing',
|
||||
fetchedAt: lookup.meta.lastSuccessfulAt ?
|
||||
new Date(lookup.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs: tbankSourceTtlMs.metadata,
|
||||
work: lookup.meta.sync.id ?
|
||||
{
|
||||
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||
? new Date(lookup.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: lookup.meta.sync.id
|
||||
? {
|
||||
id: lookup.meta.sync.id,
|
||||
state: lookup.meta.sync.state,
|
||||
errorCode: lookup.meta.sync.errorCode,
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
request(
|
||||
userId: string,
|
||||
revision: string,
|
||||
query: CatalogQuery,
|
||||
force = false,
|
||||
) {
|
||||
const input = catalogQuery(query);
|
||||
const reserved = this.reservations.then(async () => {
|
||||
const saved = await this.repository.read(input);
|
||||
if (
|
||||
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const reservation = await this.repository.reserve(
|
||||
{ userId, revision },
|
||||
input,
|
||||
this.runId,
|
||||
this.active,
|
||||
);
|
||||
if (!reservation) return null;
|
||||
const { id, attemptId } = reservation.work;
|
||||
if (!reservation.launch) {
|
||||
const current = this.producers.get(catalogScope(input));
|
||||
if (current) current.force ||= force;
|
||||
return id;
|
||||
}
|
||||
const attempt = { userId, revision, id, attemptId };
|
||||
this.active.add(attemptId);
|
||||
let resolve = () => {};
|
||||
const done = new Promise<void>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||
const key = catalogScope(input);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) {
|
||||
producer.observers.push(attempt);
|
||||
producer.force ||= force;
|
||||
} else {
|
||||
const queued = { input, attempt, observers: [], force };
|
||||
this.producers.set(key, queued);
|
||||
this.queue.push(queued);
|
||||
}
|
||||
if (!this.running) {
|
||||
this.running = true;
|
||||
setImmediate(() => {
|
||||
void this.drain();
|
||||
});
|
||||
}
|
||||
return id;
|
||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
||||
const found =
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.findShares(token, input.query)
|
||||
: await this.tbank.findBonds(token, input.query);
|
||||
const instruments = selectCatalogInstruments(
|
||||
input.exactTicker
|
||||
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
|
||||
: found,
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||
const [details, prices] = await Promise.all([
|
||||
Promise.all(
|
||||
instruments.map((item) =>
|
||||
input.instrumentType === 'share'
|
||||
? this.tbank.getShare(token, item.uid)
|
||||
: this.tbank.getBond(token, item.uid),
|
||||
),
|
||||
),
|
||||
instruments.length
|
||||
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
|
||||
: [],
|
||||
]);
|
||||
return { details, instruments, prices };
|
||||
});
|
||||
this.reservations = reserved.catch(() => undefined);
|
||||
return reserved;
|
||||
}
|
||||
|
||||
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
||||
const id = await this.request(userId, revision, query, true);
|
||||
if (id) {
|
||||
await Promise.all(
|
||||
[...this.completions.values()]
|
||||
.filter((entry) => entry.workId === id)
|
||||
.map((entry) => entry.done),
|
||||
);
|
||||
}
|
||||
const saved = await this.repository.read(catalogQuery(query));
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
query.instrumentType,
|
||||
saved.data?.map((item) => item.uid) ?? [],
|
||||
true,
|
||||
);
|
||||
return [
|
||||
...(id ? [id] : []),
|
||||
...sources.flatMap((source) =>
|
||||
source.meta.sync.id ? [source.meta.sync.id] : [],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
private async drain() {
|
||||
try {
|
||||
while (this.queue.length) {
|
||||
const producer = this.queue.shift();
|
||||
if (!producer) break;
|
||||
const { input, attempt } = producer;
|
||||
let success = false;
|
||||
try {
|
||||
const token = await this.access.tokenForAttempt(attempt);
|
||||
if (token) {
|
||||
const found =
|
||||
input.instrumentType === 'share' ?
|
||||
await this.tbank.findShares(token, input.query) :
|
||||
await this.tbank.findBonds(token, input.query);
|
||||
success = await this.repository.publish(
|
||||
attempt,
|
||||
input,
|
||||
selectCatalogInstruments(
|
||||
input.exactTicker ?
|
||||
found.filter(
|
||||
(item) =>
|
||||
item.ticker?.trim().toUpperCase() === input.query,
|
||||
) :
|
||||
found,
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(
|
||||
FIRST_RESULT_OFFSET,
|
||||
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
|
||||
),
|
||||
new Date(),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
}
|
||||
}
|
||||
if (success) {
|
||||
const saved = await this.repository.read(input);
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
|
||||
await this.enrichment.requestMany(
|
||||
observer.userId,
|
||||
observer.revision,
|
||||
input.instrumentType,
|
||||
saved.data?.map((item) => item.uid) ?? [],
|
||||
producer.force,
|
||||
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
this.producers.delete(catalogScope(input));
|
||||
for (const observer of [attempt, ...producer.observers]) {
|
||||
await this.repository.finish(observer, success);
|
||||
this.active.delete(observer.attemptId);
|
||||
this.completions.get(observer.attemptId)?.resolve();
|
||||
this.completions.delete(observer.attemptId);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
for (const completion of this.completions.values()) completion.resolve();
|
||||
this.completions.clear();
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
const fetchedAt = new Date();
|
||||
const data = instruments.flatMap((item, index) => {
|
||||
const detail = details.at(index);
|
||||
return [{
|
||||
...item,
|
||||
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||
}];
|
||||
});
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `search:${catalogScope(input)}`,
|
||||
dataRevision: `online:${fetchedAt.toISOString()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt,
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
@@ -13,7 +12,7 @@ import {
|
||||
} from '../../../../common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
@@ -36,8 +35,10 @@ import {
|
||||
catalogScope,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
const FIRST_QUEUE_INDEX = 0;
|
||||
@@ -58,7 +59,7 @@ export class CatalogEnrichmentService {
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
{ attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
async ensureDetail(
|
||||
@@ -124,9 +125,9 @@ export class CatalogEnrichmentService {
|
||||
range,
|
||||
);
|
||||
}
|
||||
const work = inputs.length ?
|
||||
await this.repository.workMany(userId, revision, inputs) :
|
||||
[];
|
||||
const work = inputs.length
|
||||
? await this.repository.workMany(userId, revision, inputs)
|
||||
: [];
|
||||
const byScope = new Map(work.map((row) => [row.scopeKey, row]));
|
||||
return inputs.map((input) => {
|
||||
const row = rows.find(
|
||||
@@ -136,16 +137,16 @@ export class CatalogEnrichmentService {
|
||||
const payload = row?.payload as CatalogPayload | undefined;
|
||||
return {
|
||||
input,
|
||||
version: row ?
|
||||
{
|
||||
version: row
|
||||
? {
|
||||
instrumentUid: row.instrumentUid,
|
||||
source: row.source,
|
||||
kind: row.kind,
|
||||
rangeKey: row.rangeKey,
|
||||
dataRevision: row.dataRevision,
|
||||
fetchedAt: row.fetchedAt,
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
payload,
|
||||
instrument: payload?.instrument ?? null,
|
||||
price: payload?.price ?? null,
|
||||
@@ -154,7 +155,7 @@ export class CatalogEnrichmentService {
|
||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
availability: row ? 'complete' : 'missing',
|
||||
fetchedAt: row?.fetchedAt ?? null,
|
||||
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||
freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
|
||||
work: byScope.get(catalogScope(input)) ?? null,
|
||||
}),
|
||||
};
|
||||
@@ -177,23 +178,19 @@ export class CatalogEnrichmentService {
|
||||
) {
|
||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||
const reservation = this.reservations.then(async () => {
|
||||
const rows = inputs.length ?
|
||||
await this.repository.readSources(inputs) :
|
||||
[];
|
||||
const rows = inputs.length
|
||||
? await this.repository.readSources(inputs)
|
||||
: [];
|
||||
const ids: string[] = [];
|
||||
for (const input of inputs) {
|
||||
const row = rows.find(
|
||||
(entry) =>
|
||||
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||
);
|
||||
if (
|
||||
!tbankSourceNeedsRefresh(
|
||||
sourcePolicy(input.kind),
|
||||
row?.fetchedAt ?? null,
|
||||
new Date(),
|
||||
force,
|
||||
)
|
||||
) {
|
||||
const policy = sourcePolicy(input.kind);
|
||||
const needsRefresh = force || !row?.fetchedAt ||
|
||||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
||||
if (!needsRefresh) {
|
||||
continue;
|
||||
}
|
||||
const reserved = await this.repository.reserve(
|
||||
@@ -212,7 +209,7 @@ export class CatalogEnrichmentService {
|
||||
const done = new Promise<void>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||
this.completions.set(attemptId, { attempt, done, resolve, workId: id });
|
||||
const key = producerKey(input);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) producer.observers.push({ attempt, input });
|
||||
@@ -241,19 +238,49 @@ export class CatalogEnrichmentService {
|
||||
section: 'company' | 'metrics' | 'quote',
|
||||
observe = false,
|
||||
) {
|
||||
if (section === 'company' && !observe) {
|
||||
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
||||
const instrument =
|
||||
instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, uid)
|
||||
: await this.tbank.getBond(token, uid);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
const [description, fundamentals] = instrument.assetUid
|
||||
? await Promise.all([
|
||||
this.tbank.getAssetDescription(token, instrument.assetUid),
|
||||
this.tbank.getFundamentals(token, instrument.assetUid),
|
||||
])
|
||||
: [null, null];
|
||||
return { description, fundamentals, instrument };
|
||||
});
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/company/${uid}`,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: null,
|
||||
});
|
||||
return {
|
||||
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
|
||||
meta,
|
||||
unavailable: [],
|
||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company' ?
|
||||
['detail', 'description', 'fundamentals'] :
|
||||
[
|
||||
...(instrumentType === 'bond' ?
|
||||
['bond-current' as const] :
|
||||
['detail' as const]),
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
: [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics' ?
|
||||
['coupons' as const, 'yield' as const, 'rating' as const] :
|
||||
[]),
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
];
|
||||
const sources = await this.readMany(
|
||||
userId,
|
||||
@@ -263,7 +290,7 @@ export class CatalogEnrichmentService {
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
||||
if (first.meta.completeness === 'complete' && !first.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const values = sources.reduce<CatalogPayload>(
|
||||
@@ -272,25 +299,22 @@ export class CatalogEnrichmentService {
|
||||
);
|
||||
const dependencies = sources.map((source) => source.meta);
|
||||
const complete = dependencies.every(
|
||||
(dependency) => dependency.availability === 'complete',
|
||||
(dependency) => dependency.completeness === 'complete',
|
||||
);
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||
dataRevision: dependencies
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: !first.instrument ?
|
||||
'missing' :
|
||||
complete ?
|
||||
'complete' :
|
||||
'partial',
|
||||
fetchedAt: first.meta.lastSuccessfulAt ?
|
||||
new Date(first.meta.lastSuccessfulAt) :
|
||||
null,
|
||||
freshForMs:
|
||||
section === 'company' ?
|
||||
tbankSourceTtlMs.company :
|
||||
tbankSourceTtlMs.quote,
|
||||
availability: !first.instrument
|
||||
? 'missing'
|
||||
: complete
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
@@ -300,7 +324,7 @@ export class CatalogEnrichmentService {
|
||||
unavailable: sources
|
||||
.filter(
|
||||
(source) =>
|
||||
source.meta.availability !== 'complete' ||
|
||||
source.meta.completeness !== 'complete' ||
|
||||
source.meta.sync.state === 'failed',
|
||||
)
|
||||
.map((source) => source.input.kind),
|
||||
@@ -308,6 +332,7 @@ export class CatalogEnrichmentService {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||
private async load(
|
||||
input: CatalogSource,
|
||||
token: string,
|
||||
@@ -315,15 +340,15 @@ export class CatalogEnrichmentService {
|
||||
): Promise<CatalogPayload> {
|
||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||
const instrument =
|
||||
input.instrumentType === 'share' ?
|
||||
await this.tbank.getShare(token, input.uid) :
|
||||
await this.tbank.getBond(token, input.uid);
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, input.uid)
|
||||
: await this.tbank.getBond(token, input.uid);
|
||||
if (instrument && instrument.uid !== input.uid) {
|
||||
throw new Error('Catalog UID mismatch');
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||
return { instrument: instrument ? publicInstrument(instrument) : null };
|
||||
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
|
||||
}
|
||||
if (input.kind === 'trading-status') {
|
||||
return {
|
||||
@@ -352,17 +377,7 @@ export class CatalogEnrichmentService {
|
||||
gap.from,
|
||||
gap.to,
|
||||
);
|
||||
const rows = response.map((candle) => {
|
||||
const time = timestamp(candle.time);
|
||||
if (!time || !candle.close) {
|
||||
throw new Error('Invalid candle response');
|
||||
}
|
||||
return {
|
||||
time,
|
||||
close: formatDecimal(decimal(candle.close)),
|
||||
isComplete: candle.isComplete === true,
|
||||
};
|
||||
});
|
||||
const rows = response.map(toSavedCandle);
|
||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||
throw new Error('Cancelled candle comparison');
|
||||
}
|
||||
@@ -400,9 +415,9 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
const details = await this.repository.readSources([
|
||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||
...(input.instrumentType === 'bond' ?
|
||||
[{ ...input, kind: 'bond-current' as const, rangeKey: undefined }] :
|
||||
[]),
|
||||
...(input.instrumentType === 'bond'
|
||||
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||
: []),
|
||||
]);
|
||||
const instrument = details
|
||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||
@@ -415,36 +430,36 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
if (input.kind === 'description') {
|
||||
return {
|
||||
description: instrument?.assetUid ?
|
||||
await this.tbank.getAssetDescription(token, instrument?.assetUid) :
|
||||
null,
|
||||
description: instrument?.assetUid
|
||||
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'fundamentals') {
|
||||
return {
|
||||
fundamentals: instrument?.assetUid ?
|
||||
await this.tbank.getFundamentals(token, instrument?.assetUid) :
|
||||
null,
|
||||
fundamentals: instrument?.assetUid
|
||||
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||
const year = Number(input.rangeKey);
|
||||
const [from, to] = input.rangeKey?.startsWith('[') ?
|
||||
(JSON.parse(input.rangeKey) as [string, string]) :
|
||||
[
|
||||
const [from, to] = input.rangeKey?.startsWith('[')
|
||||
? (JSON.parse(input.rangeKey) as [string, string])
|
||||
: [
|
||||
`${year - payoutHistoryYears}-01-01`,
|
||||
`${year + payoutFutureYears}-01-01`,
|
||||
];
|
||||
return input.kind === 'coupons' ?
|
||||
{
|
||||
return input.kind === 'coupons'
|
||||
? {
|
||||
coupons: await this.tbank.getBondCoupons(
|
||||
token,
|
||||
input.uid,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
} :
|
||||
{
|
||||
}
|
||||
: {
|
||||
dividends: await this.tbank.getDividends(
|
||||
token,
|
||||
input.uid,
|
||||
@@ -461,25 +476,26 @@ export class CatalogEnrichmentService {
|
||||
if (input.kind === 'yield') {
|
||||
return {
|
||||
effectiveYield:
|
||||
instrument?.ticker && instrument.isin && instrument.classCode ?
|
||||
await this.moex.bondYield(
|
||||
instrument?.ticker && instrument.isin && instrument.classCode
|
||||
? await this.moex.bondYield(
|
||||
instrument.ticker,
|
||||
instrument.isin,
|
||||
instrument.classCode,
|
||||
) :
|
||||
null,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'rating') {
|
||||
return {
|
||||
creditRating: instrument?.isin ?
|
||||
await this.ratings.rating(instrument.isin) :
|
||||
null,
|
||||
creditRating: instrument?.isin
|
||||
? await this.ratings.rating(instrument.isin)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
throw new Error('Unsupported catalog source');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
||||
private async drain() {
|
||||
try {
|
||||
while (this.queue.length) {
|
||||
@@ -520,11 +536,11 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
await this.access.rejectMembershipToken(producer.attempt);
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -533,17 +549,17 @@ export class CatalogEnrichmentService {
|
||||
prices?.find((entry) => entry.instrumentUid === item.input.uid) ??
|
||||
null;
|
||||
const value =
|
||||
item.input.kind === 'last-price' ?
|
||||
{
|
||||
price: price ?
|
||||
{
|
||||
item.input.kind === 'last-price'
|
||||
? {
|
||||
price: price
|
||||
? {
|
||||
instrumentUid: item.input.uid,
|
||||
price: price.price,
|
||||
time: price.time,
|
||||
} :
|
||||
null,
|
||||
} :
|
||||
payload;
|
||||
}
|
||||
: null,
|
||||
}
|
||||
: payload;
|
||||
// Keep accepting observers while publication awaits SQLite.
|
||||
item.observers.unshift({ attempt: item.attempt, input: item.input });
|
||||
for (const observer of item.observers) {
|
||||
@@ -566,13 +582,21 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Persistence failed mid-batch: finish every still-open observer
|
||||
// attempt durably so no source row is left running. Best effort — a
|
||||
// row that stays open is re-reserved by the next reader.
|
||||
for (const completion of this.completions.values()) {
|
||||
await this.repository
|
||||
.finish(completion.attempt, false)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
for (const completion of this.completions.values()) completion.resolve();
|
||||
this.completions.clear();
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
@@ -596,53 +620,20 @@ function sourceInputs(
|
||||
uid,
|
||||
kind,
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker) ?
|
||||
...(kind === 'bond-events' && (range?.tickers?.[uid] || range?.ticker)
|
||||
// eslint-disable-next-line security/detect-object-injection -- Numeric queue indexes and validated instrument UIDs select internal data only.
|
||||
{ ticker: range?.tickers?.[uid] || range?.ticker } :
|
||||
{}),
|
||||
...(['coupons', 'dividends'].includes(kind) ?
|
||||
{
|
||||
rangeKey: range ?
|
||||
JSON.stringify([range.from, range.to]) :
|
||||
String(new Date().getUTCFullYear()),
|
||||
} :
|
||||
{}),
|
||||
? { ticker: range?.tickers?.[uid] || range?.ticker }
|
||||
: {}),
|
||||
...(['coupons', 'dividends'].includes(kind)
|
||||
? {
|
||||
rangeKey: range
|
||||
? JSON.stringify([range.from, range.to])
|
||||
: String(new Date().getUTCFullYear()),
|
||||
}
|
||||
: {}),
|
||||
})),
|
||||
);
|
||||
}
|
||||
function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
return {
|
||||
uid: value.uid,
|
||||
figi: value.figi,
|
||||
assetUid: value.assetUid,
|
||||
name: value.name,
|
||||
ticker: value.ticker,
|
||||
isin: value.isin,
|
||||
classCode: value.classCode,
|
||||
realExchange: value.realExchange,
|
||||
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||
currency: value.currency,
|
||||
shareType: (value as TbankShare).shareType,
|
||||
sector: value.sector,
|
||||
lot: value.lot,
|
||||
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||
nominal: bond.nominal,
|
||||
initialNominal: bond.initialNominal,
|
||||
aciValue: bond.aciValue,
|
||||
maturityDate: bond.maturityDate,
|
||||
callDate: bond.callDate,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||
floatingCouponFlag: bond.floatingCouponFlag,
|
||||
perpetualFlag: bond.perpetualFlag,
|
||||
amortizationFlag: bond.amortizationFlag,
|
||||
subordinatedFlag: bond.subordinatedFlag,
|
||||
bondType: bond.bondType,
|
||||
riskLevel: bond.riskLevel,
|
||||
};
|
||||
}
|
||||
|
||||
const previousCloseLookbackDays = 20;
|
||||
const previousCloseLookbackMs =
|
||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||
@@ -661,6 +652,7 @@ export type CatalogPayload = {
|
||||
price?: null | TbankLastPrice;
|
||||
tradingStatus?: null | string;
|
||||
};
|
||||
|
||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
if (
|
||||
[
|
||||
@@ -673,15 +665,13 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
) {
|
||||
return 'quote';
|
||||
}
|
||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
||||
return 'metadata';
|
||||
return 'stable';
|
||||
}
|
||||
function producerKey(input: CatalogSource) {
|
||||
return input.instrumentType === 'bond' &&
|
||||
(input.kind === 'detail' || input.kind === 'bond-current') ?
|
||||
`bond-metadata:${input.uid}` :
|
||||
catalogScope(input);
|
||||
(input.kind === 'detail' || input.kind === 'bond-current')
|
||||
? `bond-metadata:${input.uid}`
|
||||
: catalogScope(input);
|
||||
}
|
||||
|
||||
const quotationScale = 9;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
|
||||
export const catalogSearchLimit = 30;
|
||||
|
||||
export function selectCatalogInstruments<T extends TbankShare>(
|
||||
@@ -9,9 +11,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
||||
): (T & { uid: string })[] {
|
||||
const seen = new Set<string>();
|
||||
const kind =
|
||||
instrumentType === 'share' ?
|
||||
'INSTRUMENT_TYPE_SHARE' :
|
||||
'INSTRUMENT_TYPE_BOND';
|
||||
instrumentType === 'share'
|
||||
? TbankInstrumentType.Share
|
||||
: TbankInstrumentType.Bond;
|
||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||
return found
|
||||
.filter(
|
||||
@@ -29,9 +31,9 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
||||
a.uid.localeCompare(b.uid),
|
||||
)
|
||||
.filter((instrument) => {
|
||||
const key = instrument.isin?.trim() ?
|
||||
`isin:${instrument.isin.trim().toUpperCase()}` :
|
||||
`uid:${instrument.uid}`;
|
||||
const key = instrument.isin?.trim()
|
||||
? `isin:${instrument.isin.trim().toUpperCase()}`
|
||||
: `uid:${instrument.uid}`;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
|
||||
@@ -10,9 +10,9 @@ const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||
const MOSCOW_OFFSET_MS = 10_800_000;
|
||||
export function timestamp(value?: TbankTimestamp): null | string {
|
||||
const seconds = Number(value?.seconds);
|
||||
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE ?
|
||||
new Date(seconds * MILLISECONDS_PER_SECOND).toISOString() :
|
||||
null;
|
||||
return Number.isFinite(seconds) && seconds > MIN_TIMESTAMP_SECONDS_EXCLUSIVE
|
||||
? new Date(seconds * MILLISECONDS_PER_SECOND).toISOString()
|
||||
: null;
|
||||
}
|
||||
export function moscowDay(iso: string): string {
|
||||
return new Date(Date.parse(iso) + MOSCOW_OFFSET_MS)
|
||||
|
||||
@@ -43,8 +43,8 @@ export function instrumentPosition(
|
||||
currentValue,
|
||||
absoluteReturn,
|
||||
averagePurchasePrice:
|
||||
positive && totalCost ?
|
||||
{
|
||||
positive && totalCost
|
||||
? {
|
||||
currency: totalCost.currency,
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
@@ -53,15 +53,15 @@ export function instrumentPosition(
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
returnPercent: positive ? percent(absoluteReturn, totalCost) : null,
|
||||
accounts: positions.map((p) => ({
|
||||
...p,
|
||||
returnPercent:
|
||||
decimalFromString(p.quantity).value > ZERO_QUANTITY ?
|
||||
percent(p.absoluteReturn, cost(p)) :
|
||||
null,
|
||||
decimalFromString(p.quantity).value > ZERO_QUANTITY
|
||||
? percent(p.absoluteReturn, cost(p))
|
||||
: null,
|
||||
})),
|
||||
isStale: portfolio.isStale,
|
||||
observedAt: portfolio.lastSuccessfulAt,
|
||||
@@ -69,8 +69,8 @@ export function instrumentPosition(
|
||||
}
|
||||
function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||
const price = position.averagePurchasePrice;
|
||||
return price ?
|
||||
{
|
||||
return price
|
||||
? {
|
||||
currency: price.currency,
|
||||
value: formatDecimal(
|
||||
multiply(
|
||||
@@ -78,8 +78,8 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
||||
decimalFromString(position.quantity),
|
||||
),
|
||||
),
|
||||
} :
|
||||
null;
|
||||
}
|
||||
: null;
|
||||
}
|
||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||
const currency = values[0]?.currency;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const share = {
|
||||
uid: 'share-uid',
|
||||
figi: 'FIGI1',
|
||||
assetUid: 'asset-1',
|
||||
name: 'Share',
|
||||
ticker: 'SHAR',
|
||||
isin: 'US000',
|
||||
classCode: 'TQBR',
|
||||
realExchange: 'REAL_EXCHANGE_MOEX',
|
||||
apiTradeAvailableFlag: true,
|
||||
currency: 'rub',
|
||||
shareType: 'SHARE_TYPE_COMMON',
|
||||
sector: 'Finance',
|
||||
lot: 1,
|
||||
brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' },
|
||||
first_1dayCandleDate: '2020-01-01T00:00:00Z',
|
||||
privateField: 'must-not-leak',
|
||||
} as unknown as TbankShare;
|
||||
|
||||
const bond = {
|
||||
uid: 'bond-uid',
|
||||
figi: 'FIGI2',
|
||||
assetUid: 'asset-2',
|
||||
name: 'Bond',
|
||||
ticker: 'BOND',
|
||||
classCode: 'TQOB',
|
||||
currency: 'rub',
|
||||
lot: 1,
|
||||
nominal: { value: '1000', currency: 'rub' },
|
||||
initialNominal: { value: '1000', currency: 'rub' },
|
||||
aciValue: { value: '10', currency: 'rub' },
|
||||
maturityDate: '2030-01-01T00:00:00Z',
|
||||
couponQuantityPerYear: 2,
|
||||
bondType: 'BOND_TYPE_EXPIRING',
|
||||
brand: null,
|
||||
privateField: 'must-not-leak',
|
||||
} as unknown as TbankBond;
|
||||
|
||||
describe('publicInstrument projection', () => {
|
||||
it('keeps approved share fields, trims brand, drops private extras', () => {
|
||||
const result = publicInstrument(share);
|
||||
expect(result.uid).toBe('share-uid');
|
||||
expect(result.shareType).toBe('SHARE_TYPE_COMMON');
|
||||
expect(result.brand).toEqual({ logoName: 'logo.png' });
|
||||
expect(result).not.toHaveProperty('privateField');
|
||||
expect(result).not.toHaveProperty('brand.title');
|
||||
});
|
||||
|
||||
it('projects bonds with missing optional fields and null brand', () => {
|
||||
const result = publicInstrument(bond);
|
||||
expect(result.nominal).toEqual({ value: '1000', currency: 'rub' });
|
||||
expect(result.shareType).toBeUndefined();
|
||||
expect(result.brand).toBeUndefined();
|
||||
expect(result.isin).toBeUndefined();
|
||||
expect(result).not.toHaveProperty('privateField');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
const share = value as TbankShare;
|
||||
return {
|
||||
uid: value.uid,
|
||||
figi: value.figi,
|
||||
assetUid: value.assetUid,
|
||||
name: value.name,
|
||||
ticker: value.ticker,
|
||||
isin: value.isin,
|
||||
classCode: value.classCode,
|
||||
realExchange: value.realExchange,
|
||||
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||
currency: value.currency,
|
||||
shareType: share.shareType,
|
||||
sector: value.sector,
|
||||
lot: value.lot,
|
||||
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||
nominal: bond.nominal,
|
||||
initialNominal: bond.initialNominal,
|
||||
aciValue: bond.aciValue,
|
||||
maturityDate: bond.maturityDate,
|
||||
callDate: bond.callDate,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||
floatingCouponFlag: bond.floatingCouponFlag,
|
||||
perpetualFlag: bond.perpetualFlag,
|
||||
amortizationFlag: bond.amortizationFlag,
|
||||
subordinatedFlag: bond.subordinatedFlag,
|
||||
bondType: bond.bondType,
|
||||
riskLevel: bond.riskLevel,
|
||||
};
|
||||
}
|
||||
@@ -37,18 +37,18 @@ export class BondCatalogService {
|
||||
data:
|
||||
result.data?.map((item) => {
|
||||
const short = toBond(item);
|
||||
const detail = item.detail ?
|
||||
toBond({ ...item.detail, uid: item.uid }) :
|
||||
short;
|
||||
const detail = item.detail
|
||||
? toBond({ ...item.detail, uid: item.uid })
|
||||
: short;
|
||||
const last = item.price;
|
||||
return {
|
||||
...detail,
|
||||
classCode: detail.classCode ?? short.classCode,
|
||||
isin: detail.isin ?? short.isin,
|
||||
apiTradeAvailable: short.apiTradeAvailable,
|
||||
pricePercent: last?.price ?
|
||||
formatDecimal(decimal(last.price)) :
|
||||
null,
|
||||
pricePercent: last?.price
|
||||
? formatDecimal(decimal(last.price))
|
||||
: null,
|
||||
priceAt: timestamp(last?.time),
|
||||
};
|
||||
}) ?? null,
|
||||
@@ -63,9 +63,9 @@ export class BondCatalogService {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
return {
|
||||
...result,
|
||||
data: result.data ?
|
||||
{ ...toBond(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -82,9 +82,9 @@ export class BondCatalogService {
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
data: result.data ?
|
||||
{ ...toBond(result.data), slug: result.data.slug } :
|
||||
null,
|
||||
data: result.data
|
||||
? { ...toBond(result.data), slug: result.data.slug }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,10 +7,12 @@ import {
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
@@ -62,15 +64,15 @@ export class BondIncomeService {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument ?
|
||||
bondMetrics(result.values, result.unavailable, result.observedAt) :
|
||||
null,
|
||||
data: result.values.instrument
|
||||
? bondMetrics(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
|
||||
async readPosition(userId: string, uid: string, observe = false) {
|
||||
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||
async readPosition(userId: string, uid: string) {
|
||||
const saved = await this.portfolios.read(userId, 'all');
|
||||
return {
|
||||
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||
@@ -86,7 +88,7 @@ export class BondIncomeService {
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||
const portfolio = await this.portfolios.read(userId, 'all');
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
'bond',
|
||||
@@ -95,22 +97,22 @@ export class BondIncomeService {
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.coupons;
|
||||
const position = portfolio.data ?
|
||||
instrumentPosition(portfolio.data, uid, 'BOND') :
|
||||
null;
|
||||
const projected = events ?
|
||||
bondCoupons(
|
||||
const position = portfolio.data
|
||||
? instrumentPosition(portfolio.data, uid, 'BOND')
|
||||
: null;
|
||||
const projected = events
|
||||
? bondCoupons(
|
||||
events,
|
||||
position,
|
||||
{ from, to, today },
|
||||
sources[1].meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
!portfolio.data,
|
||||
) :
|
||||
undefined;
|
||||
)
|
||||
: undefined;
|
||||
const result = await this.forecasts.result({
|
||||
userId,
|
||||
revision: connection.revision,
|
||||
@@ -121,9 +123,13 @@ export class BondIncomeService {
|
||||
candidate:
|
||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||
});
|
||||
return result.data || !projected ?
|
||||
result :
|
||||
{
|
||||
if (result.data?.positionAt) {
|
||||
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
|
||||
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
|
||||
}
|
||||
return result.data || !projected
|
||||
? result
|
||||
: {
|
||||
data: bondCoupons(
|
||||
events ?? [],
|
||||
null,
|
||||
@@ -131,7 +137,7 @@ export class BondIncomeService {
|
||||
projected.observedAt,
|
||||
true,
|
||||
),
|
||||
meta: { ...result.meta, availability: 'partial' },
|
||||
meta: { ...result.meta, completeness: 'incomplete' },
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -146,19 +152,19 @@ export function bondCoupons(
|
||||
const { from, to, today } = window;
|
||||
const quantity =
|
||||
position?.accounts.length &&
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY) ?
|
||||
decimalFromString(position.quantity) :
|
||||
null;
|
||||
position.accounts.every((p) => decimalFromString(p.quantity).value > ZERO_QUANTITY)
|
||||
? decimalFromString(position.quantity)
|
||||
: null;
|
||||
const coupons = events
|
||||
.map((event) => {
|
||||
const paymentDate = timestamp(event.couponDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const recordDate = timestamp(event.fixDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||
const perBond = money(event.payOneBond);
|
||||
const timing = !paymentDate ?
|
||||
('undated' as const) :
|
||||
paymentDate >= today ?
|
||||
('upcoming' as const) :
|
||||
('past' as const);
|
||||
const timing = !paymentDate
|
||||
? ('undated' as const)
|
||||
: paymentDate >= today
|
||||
? ('upcoming' as const)
|
||||
: ('past' as const);
|
||||
return {
|
||||
number: event.couponNumber ?? null,
|
||||
paymentDate,
|
||||
@@ -172,14 +178,14 @@ export function bondCoupons(
|
||||
perBond &&
|
||||
timing === 'upcoming' &&
|
||||
recordDate &&
|
||||
recordDate >= today ?
|
||||
{
|
||||
recordDate >= today
|
||||
? {
|
||||
currency: perBond.currency,
|
||||
value: formatDecimal(
|
||||
multiply(quantity, decimalFromString(perBond.value)),
|
||||
),
|
||||
} :
|
||||
null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.paymentDate ?? '').localeCompare(b.paymentDate ?? ''));
|
||||
@@ -225,8 +231,8 @@ export function bondMetrics(
|
||||
clean &&
|
||||
coupon.currency === clean.currency &&
|
||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||
decimalFromString(clean.value).value > ZERO_AMOUNT ?
|
||||
formatDecimal(
|
||||
decimalFromString(clean.value).value > ZERO_AMOUNT
|
||||
? formatDecimal(
|
||||
divide(
|
||||
multiply(
|
||||
decimalFromString(coupon.value),
|
||||
@@ -238,14 +244,14 @@ export function bondMetrics(
|
||||
),
|
||||
COUPON_YIELD_DECIMAL_PLACES,
|
||||
),
|
||||
) :
|
||||
null;
|
||||
)
|
||||
: null;
|
||||
const couponType =
|
||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED' ?
|
||||
next.couponType :
|
||||
instrument.floatingCouponFlag === true ?
|
||||
'COUPON_TYPE_FLOATING' :
|
||||
null;
|
||||
next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
|
||||
? next.couponType
|
||||
: instrument.floatingCouponFlag === true
|
||||
? TbankCouponType.Floating
|
||||
: null;
|
||||
return {
|
||||
creditRating,
|
||||
coupon,
|
||||
|
||||
@@ -48,9 +48,9 @@ export class BondMarketService {
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument ?
|
||||
bondQuote(result.values, result.unavailable, result.observedAt) :
|
||||
null,
|
||||
data: result.values.instrument
|
||||
? bondQuote(result.values, result.unavailable, result.observedAt)
|
||||
: null,
|
||||
meta: result.meta,
|
||||
};
|
||||
}
|
||||
@@ -83,8 +83,8 @@ export function bondQuote(
|
||||
const cleanPrice =
|
||||
pricePercent !== null &&
|
||||
nominal &&
|
||||
decimalFromString(nominal.value).value > ZERO_AMOUNT ?
|
||||
{
|
||||
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
||||
? {
|
||||
currency: nominal.currency,
|
||||
value: formatDecimal(
|
||||
divide(
|
||||
@@ -96,11 +96,11 @@ export function bondQuote(
|
||||
PRICE_DECIMAL_PLACES,
|
||||
),
|
||||
),
|
||||
} :
|
||||
null;
|
||||
}
|
||||
: null;
|
||||
const dirtyPrice =
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency ?
|
||||
{
|
||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
||||
? {
|
||||
currency: cleanPrice.currency,
|
||||
value: formatDecimal(
|
||||
add(
|
||||
@@ -108,8 +108,8 @@ export function bondQuote(
|
||||
decimalFromString(aci.value),
|
||||
),
|
||||
),
|
||||
} :
|
||||
null;
|
||||
}
|
||||
: null;
|
||||
return {
|
||||
pricePercent,
|
||||
cleanPrice,
|
||||
@@ -118,9 +118,9 @@ export function bondQuote(
|
||||
priceAt,
|
||||
tradingStatus,
|
||||
changePercentagePoints:
|
||||
last?.price && previous?.close ?
|
||||
formatDecimal(subtract(decimal(last.price), decimal(previous.close))) :
|
||||
null,
|
||||
last?.price && previous?.close
|
||||
? formatDecimal(subtract(decimal(last.price), decimal(previous.close)))
|
||||
: null,
|
||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||
unavailable,
|
||||
observedAt,
|
||||
|
||||
@@ -123,9 +123,9 @@ describe('bond financial projections', () => {
|
||||
{
|
||||
couponDate: time('2026-10-10'),
|
||||
payOneBond:
|
||||
scenario === 'unknown' ?
|
||||
undefined :
|
||||
money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
scenario === 'unknown'
|
||||
? undefined
|
||||
: money('30', scenario === 'currency' ? 'usd' : 'rub'),
|
||||
couponPeriod: scenario === 'period' ? 0 : 90,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -4,7 +4,7 @@ import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
|
||||
export function money(value?: TbankMoney) {
|
||||
const currency = value?.currency?.toUpperCase();
|
||||
return value && currency ?
|
||||
{ currency, value: formatDecimal(decimal(value)) } :
|
||||
null;
|
||||
return value && currency
|
||||
? { currency, value: formatDecimal(decimal(value)) }
|
||||
: null;
|
||||
}
|
||||
|
||||
@@ -119,13 +119,8 @@ export class BondController {
|
||||
position(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.readPosition(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
return this.income.readPosition(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/coupons')
|
||||
|
||||
@@ -38,9 +38,9 @@ export function candleGaps(
|
||||
gaps = gaps.flatMap((gap) => {
|
||||
if (covered.to <= gap.from || covered.from >= gap.to) return [gap];
|
||||
return [
|
||||
...(covered.from > gap.from ?
|
||||
[{ from: gap.from, to: covered.from }] :
|
||||
[]),
|
||||
...(covered.from > gap.from
|
||||
? [{ from: gap.from, to: covered.from }]
|
||||
: []),
|
||||
...(covered.to < gap.to ? [{ from: covered.to, to: gap.to }] : []),
|
||||
];
|
||||
});
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum InstrumentErrorCode {
|
||||
BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE',
|
||||
BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS',
|
||||
StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE',
|
||||
StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS',
|
||||
}
|
||||
@@ -53,12 +53,6 @@ export class CandleRepository {
|
||||
});
|
||||
}
|
||||
|
||||
coverage(uids: string[]) {
|
||||
return this.prisma.tbankCandleCoverage.findMany({
|
||||
where: { provider, instrumentUid: { in: uids }, interval },
|
||||
});
|
||||
}
|
||||
|
||||
reserve(
|
||||
owner: { revision: string; userId: string },
|
||||
scopeKey: string,
|
||||
@@ -100,14 +94,14 @@ export class CandleRepository {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
...previousIntent,
|
||||
...(intent.force ?
|
||||
{
|
||||
...(intent.force
|
||||
? {
|
||||
force: true,
|
||||
...(intent.now && intent.now > previousNow ?
|
||||
{ now: intent.now } :
|
||||
{}),
|
||||
} :
|
||||
{}),
|
||||
...(intent.now && intent.now > previousNow
|
||||
? { now: intent.now }
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -263,12 +257,12 @@ export class CandleRepository {
|
||||
// Preserve coverage outside the requested reconciliation boundaries.
|
||||
for (const previous of old) {
|
||||
for (const part of [
|
||||
...(previous.from < range.from ?
|
||||
[{ from: previous.from, to: range.from }] :
|
||||
[]),
|
||||
...(previous.to > range.to ?
|
||||
[{ from: range.to, to: previous.to }] :
|
||||
[]),
|
||||
...(previous.from < range.from
|
||||
? [{ from: previous.from, to: range.from }]
|
||||
: []),
|
||||
...(previous.to > range.to
|
||||
? [{ from: range.to, to: previous.to }]
|
||||
: []),
|
||||
]) {
|
||||
await tx.tbankCandleCoverage.create({
|
||||
data: { ...previous, ...part },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user