Compare commits
75
Commits
e76eb545d7
...
v2.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
92f216d520 |
+7
-4
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
|
|||||||
|
|
||||||
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
|
||||||
для публичных consumers.
|
для публичных consumers.
|
||||||
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет
|
Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
|
||||||
request context и ограничивает поля событий. Jest unit tests, изолированные
|
context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
|
||||||
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
|
||||||
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
Локальные источники выбираются через [Источники](../AGENTS.md#источники).
|
||||||
|
|
||||||
@@ -267,5 +267,8 @@ Tests сохраняют независимые literal wire assertions; обы
|
|||||||
## Проверка
|
## Проверка
|
||||||
|
|
||||||
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
`npm run typecheck:app`, `npm run lint`, targeted Jest по изменённому use case.
|
||||||
Смысл boundaries проверяется review: backend не имеет отдельного автоматического
|
ESLint проверяет прямые imports: domain не зависит от transport/persistence,
|
||||||
FSD/DI architecture checker. Existing tests и build не доказывают правильный owner.
|
controllers делегируют application, use cases не импортируют Prisma. Чистые
|
||||||
|
protocol inputs разрешены; existing CRUD/transaction services сохраняют DB owner.
|
||||||
|
Транзитивные зависимости, DI composition и смысл ownership проверяются review:
|
||||||
|
backend не имеет полного FSD/DI graph checker. Tests/build не доказывают owner.
|
||||||
|
|||||||
@@ -7,11 +7,19 @@
|
|||||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||||
|
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||||
|
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||||
|
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||||
|
на согласование до зависимой работы.
|
||||||
|
|
||||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
|
||||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
начинай после явного решения Change Owner.
|
||||||
|
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||||
|
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||||
|
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
|
||||||
|
не используй системный Node.
|
||||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||||
|
|
||||||
## Change scope
|
## Change scope
|
||||||
@@ -57,19 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
|||||||
|
|
||||||
## Проверки и завершение
|
## Проверки и завершение
|
||||||
|
|
||||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
| Когда | Что проверить |
|
||||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
| --- | --- |
|
||||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||||
чтения и `git diff --check`; application build ради текста не нужен.
|
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
|
||||||
|
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||||
|
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||||
|
проверку при изменении значимых входов (включая config, dependencies и
|
||||||
|
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||||
|
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||||
|
|
||||||
## Git и релизы
|
## Git и релизы
|
||||||
|
|
||||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||||
|
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||||
|
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
|
||||||
|
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
|
||||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
|
||||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
|
||||||
|
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
обычную работу. 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, раздел «Релизы».
|
||||||
|
|||||||
@@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
|
|||||||
[validated environment](../../src/config/env.validation.ts) и
|
[validated environment](../../src/config/env.validation.ts) и
|
||||||
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
[TbankClient](../../src/integrations/tbank/tbank.client.ts).
|
||||||
|
|
||||||
|
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
|
||||||
|
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
|
||||||
|
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
|
||||||
|
|
||||||
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
|
||||||
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
собственный bounded timeout и безопасный unavailable outcome; не приписывай им
|
||||||
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
|
||||||
|
|||||||
@@ -7,10 +7,12 @@
|
|||||||
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
|
||||||
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
`warn` — восстановимая проблема/retry; `error` — окончательный сбой.
|
||||||
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
|
||||||
- RequestContextService и request middleware владеют correlation: серверный
|
- Native `nestjs-pino` context и `pino-http` completion владеют correlation:
|
||||||
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно.
|
server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
|
||||||
|
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
|
||||||
|
или HTTP completion middleware не нужен.
|
||||||
Передавай существующий context, не создавай собственный UUID в каждом service.
|
Передавай существующий context, не создавай собственный UUID в каждом service.
|
||||||
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
- StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
|
||||||
tokens, cookie, Authorization, credentials, connection generation, raw body
|
tokens, cookie, Authorization, credentials, connection generation, raw body
|
||||||
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
или upstream error. Secret в `message/stack` не становится безопасным из-за
|
||||||
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
|
|||||||
проверки browser/HTTP поведения, не только TypeScript.
|
проверки browser/HTTP поведения, не только TypeScript.
|
||||||
|
|
||||||
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
Refresh rotation атомарна: один победитель, проигравший/superseded token получает
|
||||||
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace
|
401, но не меняет cookie и сам по себе не отзывает session победителя.
|
||||||
|
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
|
||||||
|
Не вводи Web Locks, replay history/grace
|
||||||
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
или иной security contract без текущей задачи. Logout идемпотентен, отзывает
|
||||||
сессию и очищает refresh cookie на HTTP-границе.
|
сессию и очищает refresh cookie на HTTP-границе.
|
||||||
|
|
||||||
|
|||||||
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
|
|||||||
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
Полный `npm run verify` включает lint, оба typecheck, Jest и build.
|
||||||
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
`npm run test:openapi-contract` — отдельный required check при wire contract change.
|
||||||
|
|
||||||
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner;
|
`npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
|
||||||
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
|
||||||
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
|
||||||
|
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
|
||||||
|
его абсолютный wall-clock budget не является merge gate на shared runner.
|
||||||
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
|
||||||
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
|
||||||
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
|
||||||
|
|||||||
@@ -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 (если есть)
|
||||||
@@ -28,9 +28,8 @@ jobs:
|
|||||||
docker version
|
docker version
|
||||||
docker buildx version
|
docker buildx version
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx --no-install prisma generate
|
|
||||||
- run: npm run verify
|
- run: npm run verify
|
||||||
- run: npm run test:e2e
|
- run: node test/sqlite.cjs --all
|
||||||
- name: Login with dedicated Nexus writer
|
- name: Login with dedicated Nexus writer
|
||||||
env:
|
env:
|
||||||
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||||
|
|||||||
@@ -34,12 +34,17 @@ jobs:
|
|||||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||||
run: |
|
run: |
|
||||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
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:
|
security:
|
||||||
needs: commits
|
needs: commits
|
||||||
if: >-
|
if: >-
|
||||||
!cancelled() &&
|
!cancelled() &&
|
||||||
|
gitea.event.action != 'edited' &&
|
||||||
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
||||||
timeout-minutes: 15
|
timeout-minutes: 15
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -60,12 +65,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- name: Audit runtime and development dependencies
|
- name: Audit runtime and development dependencies
|
||||||
run: npm audit --include=dev --audit-level=low
|
run: |
|
||||||
|
npm audit --omit=dev --audit-level=low
|
||||||
|
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
needs: [commits, security]
|
||||||
if: >-
|
if: >-
|
||||||
!cancelled() && needs.security.result == 'success' &&
|
!cancelled() && gitea.event.action != 'edited' &&
|
||||||
|
needs.security.result == 'success' &&
|
||||||
gitea.event_name != 'schedule'
|
gitea.event_name != 'schedule'
|
||||||
timeout-minutes: 45
|
timeout-minutes: 45
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -79,12 +87,10 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- name: Generate Prisma client before typechecks
|
|
||||||
run: npx --no-install prisma generate
|
|
||||||
- name: Run all backend gates
|
- name: Run all backend gates
|
||||||
run: npm run verify
|
run: npm run verify
|
||||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||||
run: npm run test:e2e
|
run: node test/sqlite.cjs --all
|
||||||
- name: Analyze develop with SonarQube
|
- name: Analyze develop with SonarQube
|
||||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||||
|
|||||||
@@ -4,6 +4,9 @@
|
|||||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||||
объединяй triggers задачи и путей; читай активированные темы до решения.
|
объединяй triggers задачи и путей; читай активированные темы до решения.
|
||||||
|
До edits найди/создай подходящую Gitea Issue, проверь AC и ветку; чтение
|
||||||
|
допускается без Issue. Общий маршрут — [Engineering Workflow](https://git.personal.ksv741.ru/WealthLedger/main/src/commit/03b3b59e6ff2ba391f9dfc42a039a96d0d84c1c9/.ai/conventions/engineering-workflow.md)
|
||||||
|
(main#28; после merge — актуальная версия `develop`).
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+155
@@ -1,5 +1,160 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [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)
|
## [0.2.3](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.2...v0.2.3) (2026-09-19)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+2
-1
@@ -19,7 +19,8 @@ EXPOSE 3000
|
|||||||
CMD ["npm", "run", "start:dev"]
|
CMD ["npm", "run", "start:dev"]
|
||||||
|
|
||||||
FROM dependencies AS build
|
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
|
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/* \
|
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
|
nvm use --silent
|
||||||
npm ci
|
npm ci
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
|
npx prisma generate
|
||||||
npm run start:dev
|
npm run start:dev
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -60,12 +61,15 @@ Compose topology принадлежит workspace, эксплуатационн
|
|||||||
npm run verify
|
npm run verify
|
||||||
```
|
```
|
||||||
|
|
||||||
Он запускает zero-warning lint,
|
Он запускает генерацию Prisma client, zero-warning lint,
|
||||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||||
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах
|
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||||
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки
|
на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
|
||||||
выполните обе команды.
|
Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
|
||||||
|
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
|
||||||
|
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
|
||||||
|
и не является функциональным merge gate.
|
||||||
|
|
||||||
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
|
||||||
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
|
||||||
@@ -77,16 +81,22 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
|||||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
|
||||||
|
и `verify` на metadata-only событии не запускаются.
|
||||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||||
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||||
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
|
||||||
`npm run verify` и `npm run test:e2e`. Используется только checkout
|
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
|
||||||
|
на уже собранных артефактах. Используется только checkout
|
||||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||||
|
|
||||||
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
|
||||||
значениями и выполняет `npm audit --include=dev --audit-level=low`.
|
значениями, блокирует любой 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` перечисляет только проверенные исторические
|
`.gitleaksignore` перечисляет только проверенные исторические
|
||||||
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
|
||||||
Новые коммиты этими исключениями не покрываются.
|
Новые коммиты этими исключениями не покрываются.
|
||||||
@@ -150,6 +160,10 @@ lint. Он не защищает от усложнения уже учтённо
|
|||||||
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
baseline командой `npx eslint . --prune-suppressions`; не пересоздавайте его
|
||||||
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
для обхода новых ошибок. Security, TypeScript и Jest в baseline не включены.
|
||||||
|
|
||||||
|
Рост baseline — изменение quality gate: он требует отдельного обоснования
|
||||||
|
и review затронутого поведения. При рефакторинге сохраняйте или сокращайте
|
||||||
|
baseline; прежнее число сообщений не доказывает прежнюю сложность функции.
|
||||||
|
|
||||||
## SonarQube
|
## SonarQube
|
||||||
|
|
||||||
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
Проект `wealthledger-backend` на `https://sonarqube.personal.ksv741.ru`
|
||||||
@@ -204,7 +218,10 @@ nvm use --silent
|
|||||||
npm run openapi:generate
|
npm run openapi:generate
|
||||||
```
|
```
|
||||||
|
|
||||||
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
Команда собирает backend и записывает `openapi.json` в корень репозитория
|
||||||
|
атомарно: через временный файл и rename, без частичной записи при сбое.
|
||||||
|
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
|
||||||
|
с ошибкой при расхождении, не перезаписывая его.
|
||||||
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||||
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||||
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||||
@@ -237,10 +254,14 @@ breaking changes относительно предыдущего выпуска.
|
|||||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||||
|
|
||||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
три последовательных и два параллельных запуска двух обычных e2e suites,
|
||||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
|
||||||
|
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
|
||||||
Логи остаются в каталоге из вывода. Для одной suite:
|
Логи остаются в каталоге из вывода. Для одной 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 |
|
| Риск | Проверка реальной DB |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -284,35 +305,59 @@ Durability оставляет временные DB, логи и `result.json`
|
|||||||
## Релизы
|
## Релизы
|
||||||
|
|
||||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||||
|
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||||
|
|
||||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
```text
|
||||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
```
|
||||||
`release/hotfix-<name>` и проходит тот же процесс.
|
|
||||||
|
Инвариант: `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,
|
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
|
||||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||||
изменился, согласуй историю и повтори проверки кандидата.
|
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||||
|
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег
|
||||||
merge создай отсутствующий тег без нового bump; до 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;
|
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
|
||||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
В 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 для одного
|
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||||
|
|||||||
@@ -1,4 +1,12 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
extends: ['@commitlint/config-conventional'],
|
extends: ['@commitlint/config-conventional'],
|
||||||
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
|
defaultIgnores: false,
|
||||||
|
ignores: [
|
||||||
|
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||||
|
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
|
||||||
|
'references-empty': [2, 'never'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,11 +4,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/common/logging/request-logging.middleware.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
"src/integrations/tbank/instrument-metadata.repository.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -37,11 +32,6 @@
|
|||||||
"count": 1
|
"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": {
|
"src/modules/investments/income/income-analytics.service.ts": {
|
||||||
"sonarjs/no-nested-template-literals": {
|
"sonarjs/no-nested-template-literals": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -54,7 +44,7 @@
|
|||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/candle.service.ts": {
|
"src/modules/investments/instruments/application/candle.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 4
|
"count": 3
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 4
|
"count": 4
|
||||||
@@ -64,17 +54,11 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 2
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
@@ -107,11 +91,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/investment-resource.mapper.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 3
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/investment-sync-plan.ts": {
|
"src/modules/investments/investment-sync-plan.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -154,30 +133,14 @@
|
|||||||
"count": 1
|
"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": {
|
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 3
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 7
|
"count": 6
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test/bank-cards-durability.cjs": {
|
"test/bank-cards-durability.cjs": {
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import globals from 'globals';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||||
|
const prismaImports = [
|
||||||
|
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||||
|
];
|
||||||
|
|
||||||
export default defineConfig(
|
export default defineConfig(
|
||||||
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
globalIgnores(['dist/**', 'dist-dev/**', 'build/**', 'generated/**', 'coverage/**', 'node_modules/**']),
|
||||||
@@ -119,6 +122,52 @@ export default defineConfig(
|
|||||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/modules/**/domain/**/*.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: [
|
||||||
|
'@nestjs/*', ...prismaImports, '**/*.dto', '**/*.repository',
|
||||||
|
'**/*.client', '**/application/**', '**/infrastructure/**',
|
||||||
|
],
|
||||||
|
message: 'Domain uses pure rules and value types, not transport or persistence.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.controller.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [
|
||||||
|
{
|
||||||
|
group: [...prismaImports, '**/*.repository', '**/*.client'],
|
||||||
|
message: 'Controllers delegate to application services or use cases.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
group: ['**'],
|
||||||
|
importNamePattern: '(?:Client|Repository|PrismaService)$',
|
||||||
|
message: 'Use application orchestration rather than data providers in controllers.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.use-case.ts'],
|
||||||
|
ignores: tests,
|
||||||
|
rules: {
|
||||||
|
'no-restricted-imports': ['error', {
|
||||||
|
patterns: [{
|
||||||
|
group: prismaImports,
|
||||||
|
message: 'Use cases obtain data through concrete repositories.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: [
|
files: [
|
||||||
'src/**/*.ts',
|
'src/**/*.ts',
|
||||||
|
|||||||
Generated
+71
-247
@@ -1,15 +1,15 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.2.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.2.0",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"better-sqlite3": "13.0.3",
|
"better-sqlite3": "12.11.1",
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "0.15.1",
|
"class-validator": "0.15.1",
|
||||||
@@ -66,7 +66,6 @@
|
|||||||
"globals": "17.12.0",
|
"globals": "17.12.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.1",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"source-map-support": "0.5.21",
|
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.2.2",
|
||||||
"ts-jest": "29.4.12",
|
"ts-jest": "29.4.12",
|
||||||
"ts-loader": "9.6.2",
|
"ts-loader": "9.6.2",
|
||||||
@@ -1425,29 +1424,6 @@
|
|||||||
"node": "^20.19.0 || ^22.13.0 || >=24"
|
"node": "^20.19.0 || ^22.13.0 || >=24"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@eslint/config-array/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
"node_modules/@eslint/config-array/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -1536,9 +1512,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@grpc/grpc-js": {
|
"node_modules/@grpc/grpc-js": {
|
||||||
"version": "1.14.4",
|
"version": "1.14.5",
|
||||||
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.4.tgz",
|
"resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.14.5.tgz",
|
||||||
"integrity": "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ==",
|
"integrity": "sha512-7VZM+SVdEcUUqSQeNI3zM8Qs/BhQKZndPo2h5VkYkAM8Iz0wJIa8mKV5ekQGqG8UUsnkQ0NMxIxwkIHYvj0qOw==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/proto-loader": "^0.8.0",
|
"@grpc/proto-loader": "^0.8.0",
|
||||||
@@ -2797,29 +2773,6 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@nestjs/cli/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
"node_modules/@nestjs/cli/node_modules/chokidar": {
|
||||||
"version": "5.0.0",
|
"version": "5.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
|
||||||
@@ -3815,20 +3768,6 @@
|
|||||||
"better-sqlite3": "^12.6.0"
|
"better-sqlite3": "^12.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@prisma/adapter-better-sqlite3/node_modules/better-sqlite3": {
|
|
||||||
"version": "12.11.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
|
||||||
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
|
||||||
"hasInstallScript": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"bindings": "^1.5.0",
|
|
||||||
"prebuild-install": "^7.1.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@prisma/client": {
|
"node_modules/@prisma/client": {
|
||||||
"version": "7.10.0",
|
"version": "7.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@prisma/client/-/client-7.10.0.tgz",
|
||||||
@@ -5095,29 +5034,6 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -6194,9 +6110,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/babel-plugin-istanbul": {
|
"node_modules/babel-plugin-istanbul": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.2.tgz",
|
||||||
"integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==",
|
"integrity": "sha512-bXWZeWQvvCZcD2uHLWVbitr4s1+YqILBbWB/szXKtHXt6crLlD6mqxHZ0qWy2X/Xx5RtQvqqW80JHAXMlp+y1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
"workspaces": [
|
"workspaces": [
|
||||||
@@ -6270,6 +6186,16 @@
|
|||||||
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
"@babel/core": "^7.11.0 || ^8.0.0-beta.1 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/balanced-match": {
|
||||||
|
"version": "4.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
||||||
|
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "18 || 20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/base64-js": {
|
"node_modules/base64-js": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
|
||||||
@@ -6304,9 +6230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/basic-ftp": {
|
"node_modules/basic-ftp": {
|
||||||
"version": "5.3.1",
|
"version": "6.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/basic-ftp/-/basic-ftp-6.2.1.tgz",
|
||||||
"integrity": "sha512-bopVNp6ugyA150DDuZfPFdt1KZ5a94ZDiwX4hMgZDzF+GttD80lEy8kj98kbyhLXnPvhtIo93mdnLIjpCAeeOw==",
|
"integrity": "sha512-bK67isD+lKq46AU8vNtjvMaT2ZqAOAmNCbxUHlFBRD4k15NWxyEjmaKtZPlgce58So4BNTjITGQOVTjL9y0ECA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -6341,15 +6267,17 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/better-sqlite3": {
|
"node_modules/better-sqlite3": {
|
||||||
"version": "13.0.3",
|
"version": "12.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-13.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
|
||||||
"integrity": "sha512-RbOBxmLBG8uvFUc15X9+9SFemKcQ0WBuISBVkpuiaUB2qblC8UWlHEjdWVoZ8AdhSwmoEgsiXKfopX0CQxaACQ==",
|
"integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
|
||||||
|
"hasInstallScript": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"node-addon-api": "^8.0.0"
|
"bindings": "^1.5.0",
|
||||||
|
"prebuild-install": "^7.1.1"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22"
|
"node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bindings": {
|
"node_modules/bindings": {
|
||||||
@@ -6415,6 +6343,19 @@
|
|||||||
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
"integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/brace-expansion": {
|
||||||
|
"version": "5.0.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
||||||
|
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"balanced-match": "^4.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.8",
|
"version": "4.28.8",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.8.tgz",
|
||||||
@@ -6506,6 +6447,7 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz",
|
||||||
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/builtin-modules": {
|
"node_modules/builtin-modules": {
|
||||||
@@ -7019,6 +6961,7 @@
|
|||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
|
"dev": true,
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 6.0"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
@@ -8358,29 +8301,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
"node_modules/eslint-plugin-import-x/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8587,29 +8507,6 @@
|
|||||||
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
"eslint": "^8.0.0 || ^9.0.0 || ^10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.12",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
|
|
||||||
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
"node_modules/eslint-plugin-sonarjs/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -8658,29 +8555,6 @@
|
|||||||
"url": "https://opencollective.com/eslint"
|
"url": "https://opencollective.com/eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/eslint/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
"node_modules/eslint/node_modules/eslint-visitor-keys": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz",
|
||||||
@@ -9053,9 +8927,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/fast-uri": {
|
"node_modules/fast-uri": {
|
||||||
"version": "3.1.7",
|
"version": "3.1.8",
|
||||||
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz",
|
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
|
||||||
"integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==",
|
"integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@@ -9537,29 +9411,6 @@
|
|||||||
"node": ">=10.13.0"
|
"node": ">=10.13.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/glob/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/glob/node_modules/minimatch": {
|
"node_modules/glob/node_modules/minimatch": {
|
||||||
"version": "10.2.6",
|
"version": "10.2.6",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
|
||||||
@@ -9653,13 +9504,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/handlebars": {
|
"node_modules/handlebars": {
|
||||||
"version": "4.7.9",
|
"version": "4.7.10",
|
||||||
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz",
|
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
|
||||||
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==",
|
"integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"minimist": "^1.2.5",
|
"minimist": "^1.2.8",
|
||||||
"neo-async": "^2.6.2",
|
"neo-async": "^2.6.2",
|
||||||
"source-map": "^0.6.1",
|
"source-map": "^0.6.1",
|
||||||
"wordwrap": "^1.0.0"
|
"wordwrap": "^1.0.0"
|
||||||
@@ -9967,9 +9818,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/ip-address": {
|
"node_modules/ip-address": {
|
||||||
"version": "10.7.0",
|
"version": "10.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.2.tgz",
|
||||||
"integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
|
"integrity": "sha512-7H/2gFSIitxc0hG3nOI1glS8QLo/EHBFFLk8vEUjXY/xu0AdL8jZ9U1IzO2PUm0d2D/ofQcAifb0g6OBkt8U7w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -11641,14 +11492,13 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "2.3.0",
|
"version": "2.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
|
||||||
"integrity": "sha512-cjNbm3sttszgZeGfJR124D+jFEfkXCVAsoPBmFn9X7UxmDSFHWqE2CoEj0vrmSpuAFnqWR1Szcm9QTsiHr60Xw==",
|
"integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.6.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^2.0.0",
|
|
||||||
"type-is": "^1.6.18"
|
"type-is": "^1.6.18"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@@ -12829,9 +12679,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/proxy-addr": {
|
"node_modules/proxy-addr": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
|
||||||
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
|
"integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"forwarded": "0.2.0",
|
"forwarded": "0.2.0",
|
||||||
@@ -12839,6 +12689,10 @@
|
|||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/proxy-agent": {
|
"node_modules/proxy-agent": {
|
||||||
@@ -13213,16 +13067,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/release-it/node_modules/undici": {
|
|
||||||
"version": "7.29.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz",
|
|
||||||
"integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20.18.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/remeda": {
|
"node_modules/remeda": {
|
||||||
"version": "2.33.4",
|
"version": "2.33.4",
|
||||||
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
"resolved": "https://registry.npmjs.org/remeda/-/remeda-2.33.4.tgz",
|
||||||
@@ -13779,6 +13623,7 @@
|
|||||||
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
"integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
@@ -13790,6 +13635,7 @@
|
|||||||
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "BSD-3-Clause",
|
"license": "BSD-3-Clause",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
@@ -14181,29 +14027,6 @@
|
|||||||
"node": ">=18"
|
"node": ">=18"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/balanced-match": {
|
|
||||||
"version": "4.0.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
|
|
||||||
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": "18 || 20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/brace-expansion": {
|
|
||||||
"version": "5.0.9",
|
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz",
|
|
||||||
"integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"balanced-match": "^4.0.2"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "20 || >=22"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/test-exclude/node_modules/glob": {
|
"node_modules/test-exclude/node_modules/glob": {
|
||||||
"version": "10.5.0",
|
"version": "10.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz",
|
||||||
@@ -14234,9 +14057,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
"node_modules/test-exclude/node_modules/glob/node_modules/brace-expansion": {
|
||||||
"version": "2.1.4",
|
"version": "2.1.7",
|
||||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
|
||||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -14633,6 +14456,7 @@
|
|||||||
"version": "0.0.6",
|
"version": "0.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz",
|
||||||
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/typescript": {
|
"node_modules/typescript": {
|
||||||
|
|||||||
+14
-9
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "0.2.3",
|
"version": "2.2.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
@@ -11,10 +11,11 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "prisma generate && nest build",
|
"build": "prisma generate && nest build",
|
||||||
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
|
||||||
|
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||||
"format": "npm run lint:fix",
|
"format": "npm run lint:fix",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||||
"start:prod": "node dist/src/main.js",
|
"start:prod": "node dist/src/main.js",
|
||||||
"lint": "npm run lint:check",
|
"lint": "npm run lint:check",
|
||||||
"lint:check": "eslint . --max-warnings=0",
|
"lint:check": "eslint . --max-warnings=0",
|
||||||
@@ -26,18 +27,19 @@
|
|||||||
"test:cov": "npm test -- --coverage",
|
"test:cov": "npm test -- --coverage",
|
||||||
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||||
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
"test:e2e": "npm run build && node test/sqlite.cjs --all",
|
||||||
|
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
|
||||||
"test:sqlite": "node test/sqlite.cjs",
|
"test:sqlite": "node test/sqlite.cjs",
|
||||||
"test:container": "node test/container-smoke.cjs",
|
"test:container": "node test/container-smoke.cjs",
|
||||||
"test:durability": "node test/bank-cards-durability.cjs",
|
"test:durability": "node test/bank-cards-durability.cjs",
|
||||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||||
"verify": "npm run lint && npm run 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:preview": "release-it --dry-run --ci",
|
||||||
"release:prepare": "release-it --ci",
|
"release:prepare": "release-it --ci",
|
||||||
"commitlint": "commitlint",
|
"commitlint": "commitlint",
|
||||||
"test:lint": "node --test test/eslint-config.cjs"
|
"test:lint": "node --test test/eslint-config.cjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.4",
|
"@grpc/grpc-js": "1.14.5",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.0.1",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.0",
|
||||||
@@ -49,7 +51,7 @@
|
|||||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.0",
|
||||||
"bcrypt": "6.0.0",
|
"bcrypt": "6.0.0",
|
||||||
"better-sqlite3": "13.0.3",
|
"better-sqlite3": "12.11.1",
|
||||||
"cheerio": "1.2.0",
|
"cheerio": "1.2.0",
|
||||||
"class-transformer": "0.5.1",
|
"class-transformer": "0.5.1",
|
||||||
"class-validator": "0.15.1",
|
"class-validator": "0.15.1",
|
||||||
@@ -94,7 +96,6 @@
|
|||||||
"globals": "17.12.0",
|
"globals": "17.12.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.1",
|
||||||
"release-it": "21.0.2",
|
"release-it": "21.0.2",
|
||||||
"source-map-support": "0.5.21",
|
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.2.2",
|
||||||
"ts-jest": "29.4.12",
|
"ts-jest": "29.4.12",
|
||||||
"ts-loader": "9.6.2",
|
"ts-loader": "9.6.2",
|
||||||
@@ -104,12 +105,17 @@
|
|||||||
"typescript-eslint": "8.70.1"
|
"typescript-eslint": "8.70.1"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
|
"proxy-addr": "2.0.8",
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.3.0"
|
"multer": "2.4.0"
|
||||||
},
|
},
|
||||||
"@prisma/config@7.10.0": {
|
"@prisma/config@7.10.0": {
|
||||||
"deepmerge-ts": "8.0.2"
|
"deepmerge-ts": "8.0.2"
|
||||||
},
|
},
|
||||||
|
"release-it": {
|
||||||
|
"basic-ftp": "6.2.1",
|
||||||
|
"undici": "7.29.1"
|
||||||
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"mysql2": "3.24.4"
|
"mysql2": "3.24.4"
|
||||||
}
|
}
|
||||||
@@ -138,7 +144,6 @@
|
|||||||
"better-sqlite3@12.11.1": true,
|
"better-sqlite3@12.11.1": true,
|
||||||
"fsevents@2.3.3": true,
|
"fsevents@2.3.3": true,
|
||||||
"protobufjs@7.6.5": true,
|
"protobufjs@7.6.5": true,
|
||||||
"unrs-resolver@1.12.2": true,
|
"unrs-resolver@1.12.2": true
|
||||||
"better-sqlite3@13.0.3": 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[]
|
bankCardOperations BankCardOperation[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
tbankConnection TbankConnection?
|
tbankConnection TbankConnection?
|
||||||
|
tbankValueHistory TbankPortfolioValueHistory[]
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
}
|
}
|
||||||
@@ -69,6 +70,34 @@ model TbankPortfolio {
|
|||||||
@@id([userId, revision, accountId])
|
@@id([userId, revision, accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Account valuation history survives connection replacement and stays hidden
|
||||||
|
// until the current connection confirms the account again.
|
||||||
|
model TbankPortfolioValueHistory {
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
deletedAt DateTime?
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
points TbankPortfolioValuePoint[]
|
||||||
|
|
||||||
|
@@id([userId, accountId])
|
||||||
|
}
|
||||||
|
|
||||||
|
// One row per currency of a fully valued successful portfolio attempt.
|
||||||
|
model TbankPortfolioValuePoint {
|
||||||
|
userId String
|
||||||
|
accountId String
|
||||||
|
attemptId String
|
||||||
|
currency String
|
||||||
|
value String
|
||||||
|
capturedAt DateTime
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
|
@@id([userId, accountId, attemptId, currency])
|
||||||
|
@@index([userId, accountId, capturedAt])
|
||||||
|
}
|
||||||
|
|
||||||
model TbankOperation {
|
model TbankOperation {
|
||||||
userId String
|
userId String
|
||||||
revision String
|
revision String
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
|
|||||||
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
|
||||||
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
|
||||||
|
|
||||||
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
|
|
||||||
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
|
||||||
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
|
||||||
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
const { randomBytes } = require('node:crypto');
|
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.
|
// Schema generation needs no deployment configuration or real credentials.
|
||||||
Object.assign(process.env, {
|
Object.assign(process.env, {
|
||||||
@@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core');
|
|||||||
const { AppModule } = require('../dist/src/app.module.js');
|
const { AppModule } = require('../dist/src/app.module.js');
|
||||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||||
|
|
||||||
async function generate() {
|
async function render() {
|
||||||
const app = await NestFactory.create(AppModule, {
|
const app = await NestFactory.create(AppModule, {
|
||||||
preview: true,
|
preview: true,
|
||||||
logger: false,
|
logger: false,
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
await writeFile(
|
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
|
||||||
'openapi.json',
|
|
||||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
|
||||||
);
|
|
||||||
} finally {
|
} finally {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
generate().catch((error) => {
|
// Read-only: compares the runtime document with the committed artifact and never writes.
|
||||||
console.error(error);
|
async function check(content) {
|
||||||
process.exitCode = 1;
|
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 { Module } from '@nestjs/common';
|
||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
import { ConfigModule } from '@nestjs/config';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { LoggingModule } from './common/logging/logging.module';
|
import { LoggingModule } from './common/logging/logging.module';
|
||||||
import { Environment, validateEnv } from './config/env.validation';
|
import { validateEnv } from './config/env.validation';
|
||||||
import { AuthModule } from './modules/auth/auth.module';
|
import { AuthModule } from './modules/auth/auth.module';
|
||||||
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
|
||||||
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
|
||||||
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
isGlobal: true,
|
isGlobal: true,
|
||||||
validate: validateEnv,
|
validate: validateEnv,
|
||||||
}),
|
}),
|
||||||
LoggerModule.forRootAsync({
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (config: ConfigService<Environment, true>) => ({
|
|
||||||
pinoHttp: {
|
|
||||||
level: config.getOrThrow('LOG_LEVEL'),
|
|
||||||
messageKey: 'message',
|
|
||||||
autoLogging: false,
|
|
||||||
genReqId: (request: unknown, response) => {
|
|
||||||
const existingId = (request as { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingId === 'string' ? existingId : randomUUID();
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
return requestId;
|
|
||||||
},
|
|
||||||
redact: [
|
|
||||||
'req.headers.authorization',
|
|
||||||
'req.headers.cookie',
|
|
||||||
'req.body',
|
|
||||||
],
|
|
||||||
serializers: {
|
|
||||||
req: (request: unknown) => {
|
|
||||||
const value = request as { id?: unknown; method?: unknown };
|
|
||||||
return {
|
|
||||||
id: typeof value.id === 'string' ? value.id : undefined,
|
|
||||||
method:
|
|
||||||
typeof value.method === 'string' ? value.method : undefined,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
LoggingModule,
|
LoggingModule,
|
||||||
PrismaModule,
|
PrismaModule,
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||||
|
|
||||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||||
import {
|
import {
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
|
|||||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||||
|
|
||||||
it('requires credentials and normalizes a valid email', async () => {
|
it('requires credentials and normalizes a valid email', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
const metadata = { type: 'body' as const, metatype: RegisterDto };
|
||||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||||
BadRequestException,
|
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 () => {
|
it('requires and validates nested money in a new card', async () => {
|
||||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||||
const card = {
|
const card = {
|
||||||
|
|||||||
@@ -1,16 +1,66 @@
|
|||||||
import { Global, Module } from '@nestjs/common';
|
import type { Request, Response } from 'express';
|
||||||
|
import type { Options } from 'pino-http';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
import { Global, Module } from '@nestjs/common';
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
import { Environment } from '../../config/env.validation';
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
|
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
function completion(request: Request, response: Response, value: { responseTime: number }) {
|
||||||
|
const route: unknown = request.route;
|
||||||
|
const path = typeof route === 'object' && route !== null && 'path' in route &&
|
||||||
|
typeof route.path === 'string'
|
||||||
|
? `${request.baseUrl}${route.path}`
|
||||||
|
: '<unmatched>';
|
||||||
|
return {
|
||||||
|
method: request.method,
|
||||||
|
route: path,
|
||||||
|
status: response.statusCode,
|
||||||
|
durationMs: Math.round(value.responseTime),
|
||||||
|
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
|
||||||
|
return {
|
||||||
|
level: config.getOrThrow('LOG_LEVEL'),
|
||||||
|
messageKey: 'message',
|
||||||
|
genReqId: (_request, response) => {
|
||||||
|
const id = randomUUID();
|
||||||
|
response.setHeader('X-Request-ID', id);
|
||||||
|
return id;
|
||||||
|
},
|
||||||
|
customProps: (request) => {
|
||||||
|
const incoming = request.header('x-request-id');
|
||||||
|
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
|
||||||
|
},
|
||||||
|
customLogLevel: (_request, response) => {
|
||||||
|
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
|
||||||
|
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
|
||||||
|
return 'info';
|
||||||
|
},
|
||||||
|
customSuccessMessage: () => 'http.request.completed',
|
||||||
|
customErrorMessage: () => 'http.request.completed',
|
||||||
|
customSuccessObject: completion,
|
||||||
|
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
|
||||||
|
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
|
||||||
|
serializers: { req: () => undefined },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@Global()
|
@Global()
|
||||||
@Module({
|
@Module({
|
||||||
providers: [
|
imports: [LoggerModule.forRootAsync<Request, Response>({
|
||||||
RequestContextService,
|
inject: [ConfigService],
|
||||||
RequestLoggingMiddleware,
|
useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
|
||||||
StructuredLogger,
|
})],
|
||||||
],
|
providers: [StructuredLogger],
|
||||||
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger],
|
exports: [StructuredLogger],
|
||||||
})
|
})
|
||||||
export class LoggingModule {}
|
export class LoggingModule {}
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
|
||||||
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
||||||
|
|
||||||
export type RequestLogContext = {
|
|
||||||
brokerageAccountId?: string;
|
|
||||||
clientRequestId?: string;
|
|
||||||
requestId: string;
|
|
||||||
userId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestContextService {
|
|
||||||
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
|
|
||||||
|
|
||||||
run<T>(context: RequestLogContext, callback: () => T): T {
|
|
||||||
return this.storage.run(context, callback);
|
|
||||||
}
|
|
||||||
|
|
||||||
get(): RequestLogContext | undefined {
|
|
||||||
return this.storage.getStore();
|
|
||||||
}
|
|
||||||
|
|
||||||
set(context: Partial<RequestLogContext>): void {
|
|
||||||
const current = this.storage.getStore();
|
|
||||||
if (current) Object.assign(current, context);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
import { EventEmitter } from 'node:events';
|
|
||||||
|
|
||||||
import { RequestLoggingMiddleware } from './request-logging.middleware';
|
|
||||||
|
|
||||||
describe('RequestLoggingMiddleware', () => {
|
|
||||||
it('returns a server ID and emits one safe completion event', () => {
|
|
||||||
const requestContext = {
|
|
||||||
run: jest.fn((_: unknown, next: () => void) => next()),
|
|
||||||
};
|
|
||||||
const logger = { event: jest.fn() };
|
|
||||||
const middleware = new RequestLoggingMiddleware(
|
|
||||||
requestContext as never,
|
|
||||||
logger as never,
|
|
||||||
);
|
|
||||||
const response = Object.assign(new EventEmitter(), {
|
|
||||||
statusCode: 200,
|
|
||||||
locals: {},
|
|
||||||
setHeader: jest.fn(),
|
|
||||||
});
|
|
||||||
const next = jest.fn();
|
|
||||||
|
|
||||||
const request: {
|
|
||||||
baseUrl: string;
|
|
||||||
header: jest.Mock<string>;
|
|
||||||
id?: string;
|
|
||||||
method: string;
|
|
||||||
route: { path: string };
|
|
||||||
} = {
|
|
||||||
method: 'GET',
|
|
||||||
baseUrl: '/api/v1',
|
|
||||||
route: { path: '/accounts/:id' },
|
|
||||||
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
|
|
||||||
};
|
|
||||||
middleware.use(request as never, response as never, next);
|
|
||||||
response.emit('finish');
|
|
||||||
|
|
||||||
expect(response.setHeader).toHaveBeenCalledWith(
|
|
||||||
'X-Request-ID',
|
|
||||||
expect.stringMatching(/^[0-9a-f-]{36}$/i),
|
|
||||||
);
|
|
||||||
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
|
|
||||||
expect(requestContext.run).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
|
|
||||||
}),
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'info',
|
|
||||||
'http.request.completed',
|
|
||||||
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
|
||||||
|
|
||||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
import { constants as httpStatus } from 'node:http2';
|
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
import { StructuredLogger } from './structured-logger.service';
|
|
||||||
|
|
||||||
const uuid =
|
|
||||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
||||||
|
|
||||||
@Injectable()
|
|
||||||
export class RequestLoggingMiddleware implements NestMiddleware {
|
|
||||||
constructor(
|
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly logger: StructuredLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
use(request: Request, response: Response, next: NextFunction): void {
|
|
||||||
const incoming = request.header('x-request-id');
|
|
||||||
const clientRequestId =
|
|
||||||
incoming && uuid.test(incoming) ? incoming : undefined;
|
|
||||||
const existingRequestId = (request as Request & { id?: unknown }).id;
|
|
||||||
const requestId =
|
|
||||||
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
|
|
||||||
(request as unknown as { id: string }).id = requestId;
|
|
||||||
const startedAt = performance.now();
|
|
||||||
|
|
||||||
response.setHeader('X-Request-ID', requestId);
|
|
||||||
response.on('finish', () => {
|
|
||||||
const routeRequest: unknown = request;
|
|
||||||
const routePath = (routeRequest as { route?: { path?: unknown } }).route
|
|
||||||
?.path;
|
|
||||||
const route =
|
|
||||||
typeof routePath === 'string'
|
|
||||||
? `${request.baseUrl}${routePath}`
|
|
||||||
: '<unmatched>';
|
|
||||||
const context = {
|
|
||||||
method: request.method,
|
|
||||||
route,
|
|
||||||
status: response.statusCode,
|
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
|
||||||
...(response.locals.publicErrorCode
|
|
||||||
? { code: response.locals.publicErrorCode as string }
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
const level =
|
|
||||||
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
|
|
||||||
? 'error'
|
|
||||||
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
|
|
||||||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
|
|
||||||
? 'warn'
|
|
||||||
: 'info';
|
|
||||||
this.logger.event(level, 'http.request.completed', context);
|
|
||||||
});
|
|
||||||
|
|
||||||
this.requestContext.run(
|
|
||||||
{
|
|
||||||
requestId,
|
|
||||||
...(clientRequestId ? { clientRequestId } : {}),
|
|
||||||
},
|
|
||||||
next,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
import { StructuredLogger } from './structured-logger.service';
|
import { StructuredLogger } from './structured-logger.service';
|
||||||
|
|
||||||
describe('StructuredLogger', () => {
|
describe('StructuredLogger', () => {
|
||||||
const context = { get: jest.fn() };
|
|
||||||
const pino = {
|
const pino = {
|
||||||
debug: jest.fn(),
|
debug: jest.fn(),
|
||||||
info: jest.fn(),
|
info: jest.fn(),
|
||||||
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
|
|||||||
beforeEach(() => jest.clearAllMocks());
|
beforeEach(() => jest.clearAllMocks());
|
||||||
|
|
||||||
it('passes only allowlisted fields to Pino', () => {
|
it('passes only allowlisted fields to Pino', () => {
|
||||||
context.get.mockReturnValue({ requestId: 'request', token: 'secret' });
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.info('event', {
|
logger.info('event', {
|
||||||
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
password: 'secret',
|
password: 'secret',
|
||||||
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
|
|||||||
|
|
||||||
expect(pino.info).toHaveBeenCalledWith(
|
expect(pino.info).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
requestId: 'request',
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
},
|
},
|
||||||
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('delegates event levels to Pino', () => {
|
it('delegates event levels to Pino', () => {
|
||||||
context.get.mockReturnValue(undefined);
|
const logger = new StructuredLogger(pino as never);
|
||||||
const logger = new StructuredLogger(context as never, pino as never);
|
|
||||||
|
|
||||||
logger.event('debug', 'debug event');
|
logger.event('debug', 'debug event');
|
||||||
logger.event('warn', 'warn event');
|
logger.event('warn', 'warn event');
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
import { Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||||
import { PinoLogger } from 'nestjs-pino';
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from './request-context.service';
|
|
||||||
|
|
||||||
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||||
|
|
||||||
const allowedFields = new Set([
|
const allowedFields = new Set([
|
||||||
|
'asOf',
|
||||||
'attempts',
|
'attempts',
|
||||||
'brokerageAccountId',
|
'brokerageAccountId',
|
||||||
|
'channelState',
|
||||||
'clientRequestId',
|
'clientRequestId',
|
||||||
'code',
|
'code',
|
||||||
'count',
|
'count',
|
||||||
@@ -16,21 +16,22 @@ const allowedFields = new Set([
|
|||||||
'environment',
|
'environment',
|
||||||
'found',
|
'found',
|
||||||
'grpcCode',
|
'grpcCode',
|
||||||
|
'headersReceived',
|
||||||
'method',
|
'method',
|
||||||
|
'networkMs',
|
||||||
'operation',
|
'operation',
|
||||||
|
'queueWaitMs',
|
||||||
'requestId',
|
'requestId',
|
||||||
'route',
|
'route',
|
||||||
'service',
|
'service',
|
||||||
'status',
|
'status',
|
||||||
|
'trackingId',
|
||||||
'userId',
|
'userId',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StructuredLogger implements OnApplicationShutdown {
|
export class StructuredLogger implements OnApplicationShutdown {
|
||||||
constructor(
|
constructor(private readonly pino: PinoLogger) {}
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly pino: PinoLogger,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
info(message: string, context?: LogContext): void {
|
info(message: string, context?: LogContext): void {
|
||||||
this.write('info', message, context);
|
this.write('info', message, context);
|
||||||
@@ -51,7 +52,7 @@ export class StructuredLogger implements OnApplicationShutdown {
|
|||||||
): void {
|
): void {
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
|
||||||
this.pino[level](
|
this.pino[level](
|
||||||
this.safeContext({ ...this.requestContext.get(), ...context }),
|
this.safeContext(context),
|
||||||
message,
|
message,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import type { TbankInstrument } from './tbank.client';
|
|||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -320,11 +319,7 @@ function selectMetadata(
|
|||||||
return {
|
return {
|
||||||
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||||
complete,
|
complete,
|
||||||
fresh:
|
fresh: complete,
|
||||||
complete &&
|
|
||||||
// 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,
|
|
||||||
fetchedAt,
|
fetchedAt,
|
||||||
dataRevision: used
|
dataRevision: used
|
||||||
.map(
|
.map(
|
||||||
|
|||||||
@@ -121,6 +121,23 @@ export class TbankAccessService {
|
|||||||
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
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(
|
async publishMembership(
|
||||||
attempt: TbankSyncAttempt,
|
attempt: TbankSyncAttempt,
|
||||||
accounts: TbankAccount[],
|
accounts: TbankAccount[],
|
||||||
@@ -192,6 +209,17 @@ export class TbankAccessService {
|
|||||||
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
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) {
|
if (completeWork) {
|
||||||
await tx.tbankSyncWork.update({
|
await tx.tbankSyncWork.update({
|
||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type {
|
|||||||
export type TbankShare = TbankInstrument & {
|
export type TbankShare = TbankInstrument & {
|
||||||
apiTradeAvailableFlag?: boolean;
|
apiTradeAvailableFlag?: boolean;
|
||||||
assetUid?: string;
|
assetUid?: string;
|
||||||
|
buyAvailableFlag?: boolean;
|
||||||
classCode?: string;
|
classCode?: string;
|
||||||
|
countryOfRisk?: string;
|
||||||
currency?: string;
|
currency?: string;
|
||||||
figi?: string;
|
figi?: string;
|
||||||
first_1dayCandleDate?: TbankTimestamp;
|
first_1dayCandleDate?: TbankTimestamp;
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
|
|||||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentStatus {
|
||||||
|
All = 'INSTRUMENT_STATUS_ALL',
|
||||||
|
}
|
||||||
|
|
||||||
export enum TbankInstrumentType {
|
export enum TbankInstrumentType {
|
||||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from './tbank-source.policy';
|
} from './tbank-source.policy';
|
||||||
|
|
||||||
describe('request-driven source freshness', () => {
|
describe('mutable source freshness', () => {
|
||||||
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||||
'%s expires at its boundary without changing source time',
|
'%s expires at its boundary without changing source time',
|
||||||
(source) => {
|
(source) => {
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
// Freshness never establishes completeness. Immutable ranges are planned by
|
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||||
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||||
export const tbankSourceTtlMs = {
|
export const tbankSourceTtlMs = {
|
||||||
membership: 300_000,
|
membership: 3_600_000,
|
||||||
portfolio: 60_000,
|
portfolio: 3_600_000,
|
||||||
quote: 60_000,
|
quote: 3_600_000,
|
||||||
candleTail: 60_000,
|
candleTail: 3_600_000,
|
||||||
operationsTail: 300_000,
|
|
||||||
metadata: 86_400_000,
|
|
||||||
catalog: 86_400_000,
|
|
||||||
company: 86_400_000,
|
|
||||||
calendar: 86_400_000,
|
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export function tbankSourceNeedsRefresh(
|
export function tbankSourceNeedsRefresh(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { status } from '@grpc/grpc-js';
|
import { Metadata, status } from '@grpc/grpc-js';
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
|
||||||
import { TbankOperationState } from './tbank-protocol';
|
import { TbankOperationState } from './tbank-protocol';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
@@ -136,6 +137,70 @@ describe('TbankClient operations', () => {
|
|||||||
);
|
);
|
||||||
jest.useRealTimers();
|
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', () => {
|
describe('TbankClient metadata retries', () => {
|
||||||
@@ -176,46 +241,30 @@ describe('TbankClient metadata retries', () => {
|
|||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
status.RESOURCE_EXHAUSTED,
|
status.RESOURCE_EXHAUSTED,
|
||||||
])(
|
])(
|
||||||
'recovers metadata after transient gRPC status %s with 2s/4s pauses',
|
'recovers metadata after one retry for transient gRPC status %s',
|
||||||
async (code) => {
|
async (code) => {
|
||||||
requestRpc
|
requestRpc
|
||||||
.mockRejectedValueOnce({ code })
|
|
||||||
.mockRejectedValueOnce({ code })
|
.mockRejectedValueOnce({ code })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid');
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
await jest.advanceTimersByTimeAsync(999);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(1);
|
expect(requestRpc).toHaveBeenCalledTimes(1);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
await jest.advanceTimersByTimeAsync(1);
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
expect(requestRpc).toHaveBeenCalledTimes(2);
|
||||||
await jest.advanceTimersByTimeAsync(3999);
|
|
||||||
expect(requestRpc).toHaveBeenCalledTimes(2);
|
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
await expect(result).resolves.toEqual(instrument);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('rechecks a full queue before admitting a metadata retry', async () => {
|
it('rejects metadata immediately when the queue is full', async () => {
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
requestRpc.mockResolvedValue({ instrument });
|
requestRpc.mockResolvedValue({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByFigi('token', 'figi');
|
await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(1999);
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
expect(queue.run).not.toHaveBeenCalled();
|
||||||
queue.size = 49;
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
await jest.advanceTimersByTimeAsync(1);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
|
||||||
expect(logger.event).toHaveBeenCalledWith(
|
|
||||||
'warn',
|
|
||||||
'tbank.rpc.retry',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
|
|
||||||
);
|
|
||||||
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not bypass queue admission after an upstream failure', async () => {
|
it('does not bypass queue admission after an upstream failure', async () => {
|
||||||
@@ -223,42 +272,22 @@ describe('TbankClient metadata retries', () => {
|
|||||||
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
.mockRejectedValueOnce({ code: status.UNAVAILABLE })
|
||||||
.mockResolvedValueOnce({ instrument });
|
.mockResolvedValueOnce({ instrument });
|
||||||
|
|
||||||
const result = client.getInstrumentByUid('token', 'uid');
|
const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
|
||||||
queue.size = 50;
|
queue.size = 50;
|
||||||
await jest.advanceTimersByTimeAsync(2000);
|
await jest.advanceTimersByTimeAsync(1000);
|
||||||
expect(queue.run).toHaveBeenCalledTimes(1);
|
expect(queue.run).toHaveBeenCalledTimes(1);
|
||||||
queue.size = 0;
|
expect(await result).toBeInstanceOf(ServiceUnavailableException);
|
||||||
await jest.advanceTimersByTimeAsync(4000);
|
|
||||||
|
|
||||||
await expect(result).resolves.toEqual(instrument);
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stops retrying a saturated queue after three admission attempts', async () => {
|
|
||||||
queue.size = 50;
|
|
||||||
await Promise.all([
|
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(queue.run).not.toHaveBeenCalled();
|
|
||||||
expect(logger.event).toHaveBeenCalledTimes(3);
|
|
||||||
expect(logger.event).toHaveBeenLastCalledWith(
|
|
||||||
'error',
|
|
||||||
'tbank.rpc.failed',
|
|
||||||
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
|
|
||||||
);
|
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops retrying an unavailable upstream after three RPCs', async () => {
|
it('stops retrying an unavailable upstream after two RPCs', async () => {
|
||||||
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
|
||||||
jest.advanceTimersByTimeAsync(6000),
|
jest.advanceTimersByTimeAsync(1000),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(queue.run).toHaveBeenCalledTimes(3);
|
expect(queue.run).toHaveBeenCalledTimes(2);
|
||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -318,3 +347,141 @@ describe('TbankClient metadata retries', () => {
|
|||||||
expect(jest.getTimerCount()).toBe(0);
|
expect(jest.getTimerCount()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('TbankClient share catalog', () => {
|
||||||
|
const token = 'token';
|
||||||
|
const catalogSize = 40;
|
||||||
|
let client: TbankClient;
|
||||||
|
let call: jest.Mock;
|
||||||
|
let logger: {
|
||||||
|
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
call = jest.fn();
|
||||||
|
logger = {
|
||||||
|
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||||
|
};
|
||||||
|
client = Object.assign(
|
||||||
|
Object.create(TbankClient.prototype) as TbankClient,
|
||||||
|
{ call, logger },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||||
|
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||||
|
call.mockResolvedValue({ instruments });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||||
|
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||||
|
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||||
|
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||||
|
const fitting = index === catalogSize - 1;
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: fitting,
|
||||||
|
buyAvailableFlag: fitting,
|
||||||
|
ticker: fitting ? 'FIT' : `T${index}`,
|
||||||
|
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
call.mockResolvedValue({ instruments: catalog });
|
||||||
|
|
||||||
|
const shares = await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(shares).toEqual(catalog);
|
||||||
|
expect(shares).toHaveLength(catalogSize);
|
||||||
|
expect(shares).toContainEqual({
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
ticker: 'FIT',
|
||||||
|
uid: 'uid-fit',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||||
|
const error = new ServiceUnavailableException({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
call.mockRejectedValue(error);
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects when the response carries no instrument list', async () => {
|
||||||
|
call.mockResolvedValue({});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
const failure = await client
|
||||||
|
.getAllShares(token)
|
||||||
|
.catch((error: unknown) => error);
|
||||||
|
|
||||||
|
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||||
|
code: 'TBANK_UNAVAILABLE',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [
|
||||||
|
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||||
|
{ ticker: 'BROKEN' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||||
|
call.mockResolvedValue({
|
||||||
|
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.getAllShares(token);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'info',
|
||||||
|
'tbank.shares.catalog.fetched',
|
||||||
|
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||||
|
call.mockResolvedValue({ instruments: [] });
|
||||||
|
|
||||||
|
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||||
|
ServiceUnavailableException,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(logger.event).toHaveBeenCalledWith(
|
||||||
|
'error',
|
||||||
|
'tbank.shares.catalog.unusable',
|
||||||
|
expect.objectContaining({ operation: 'shares' }),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||||
|
'secret-token',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
ChannelCredentials,
|
ChannelCredentials,
|
||||||
Client,
|
Client,
|
||||||
ClientUnaryCall,
|
ClientUnaryCall,
|
||||||
|
connectivityState,
|
||||||
loadPackageDefinition,
|
loadPackageDefinition,
|
||||||
Metadata,
|
Metadata,
|
||||||
ServiceError,
|
ServiceError,
|
||||||
@@ -34,6 +35,7 @@ import {
|
|||||||
InstrumentIdType,
|
InstrumentIdType,
|
||||||
TbankAccountStatus,
|
TbankAccountStatus,
|
||||||
TbankCandleInterval,
|
TbankCandleInterval,
|
||||||
|
TbankInstrumentStatus,
|
||||||
TbankInstrumentType,
|
TbankInstrumentType,
|
||||||
TbankOperationState,
|
TbankOperationState,
|
||||||
} from './tbank-protocol';
|
} from './tbank-protocol';
|
||||||
@@ -46,10 +48,8 @@ const NO_RPC_ATTEMPTS = 0;
|
|||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
|
|
||||||
const RATE_WINDOW_MS = 1_000;
|
const RATE_WINDOW_MS = 1_000;
|
||||||
const METADATA_MAX_ATTEMPTS = 3;
|
|
||||||
const RPC_MAX_ATTEMPTS = 2;
|
const RPC_MAX_ATTEMPTS = 2;
|
||||||
const MAX_QUEUE_SIZE = 50;
|
const MAX_QUEUE_SIZE = 50;
|
||||||
const METADATA_RETRY_DELAY_MS = 2_000;
|
|
||||||
const RPC_RETRY_DELAY_MS = 1_000;
|
const RPC_RETRY_DELAY_MS = 1_000;
|
||||||
|
|
||||||
const requestDeadlineMs = 15_000;
|
const requestDeadlineMs = 15_000;
|
||||||
@@ -61,6 +61,13 @@ type Unary<Request, Response> = (
|
|||||||
callback: (error: null | ServiceError, response: Response) => void,
|
callback: (error: null | ServiceError, response: Response) => void,
|
||||||
) => ClientUnaryCall;
|
) => ClientUnaryCall;
|
||||||
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
type TbankClientService = Client & Record<string, Unary<never, never>>;
|
||||||
|
type RpcDiagnostics = {
|
||||||
|
channelState?: string;
|
||||||
|
headersReceived?: boolean;
|
||||||
|
networkMs?: number;
|
||||||
|
queueWaitMs?: number;
|
||||||
|
trackingId?: string;
|
||||||
|
};
|
||||||
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
export type TbankDecimal = { nano?: number | string; units?: number | string };
|
||||||
export type TbankMoney = TbankDecimal & { currency?: string };
|
export type TbankMoney = TbankDecimal & { currency?: string };
|
||||||
export type TbankAccount = {
|
export type TbankAccount = {
|
||||||
@@ -327,6 +334,35 @@ export class TbankClient {
|
|||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getAllShares(token: string): Promise<TbankShare[]> {
|
||||||
|
const response = await this.call<{ instruments?: TbankShare[] }>(
|
||||||
|
token,
|
||||||
|
this.instruments,
|
||||||
|
'shares',
|
||||||
|
{ instrumentStatus: TbankInstrumentStatus.All },
|
||||||
|
);
|
||||||
|
const { instruments } = response;
|
||||||
|
if (
|
||||||
|
!Array.isArray(instruments) ||
|
||||||
|
!instruments.length ||
|
||||||
|
instruments.some((item) => !item?.uid)
|
||||||
|
) {
|
||||||
|
this.logger.event('error', 'tbank.shares.catalog.unusable', {
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
throw new ServiceUnavailableException({
|
||||||
|
code: TbankErrorCode.Unavailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.logger.event('info', 'tbank.shares.catalog.fetched', {
|
||||||
|
asOf: new Date().toISOString(),
|
||||||
|
count: instruments.length,
|
||||||
|
operation: 'shares',
|
||||||
|
});
|
||||||
|
return instruments;
|
||||||
|
}
|
||||||
|
|
||||||
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
findBonds(token: string, query: string): Promise<TbankBond[]> {
|
||||||
return this.call<{ instruments?: TbankBond[] }>(
|
return this.call<{ instruments?: TbankBond[] }>(
|
||||||
token,
|
token,
|
||||||
@@ -419,12 +455,6 @@ export class TbankClient {
|
|||||||
).then((response) => response.lastPrices ?? []);
|
).then((response) => response.lastPrices ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
|
|
||||||
return this.getLastPrices(token, [uid]).then(
|
|
||||||
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
getTradingStatus(token: string, uid: string): Promise<null | string> {
|
||||||
return this.call<{ tradingStatus?: string }>(
|
return this.call<{ tradingStatus?: string }>(
|
||||||
token,
|
token,
|
||||||
@@ -486,19 +516,26 @@ export class TbankClient {
|
|||||||
request: object,
|
request: object,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS;
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
|
|
||||||
const startedAt = performance.now();
|
const startedAt = performance.now();
|
||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
|
const diagnostics: RpcDiagnostics = {};
|
||||||
try {
|
try {
|
||||||
if (queueFull) {
|
if (queueFull) {
|
||||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
const response = await this.queue.run(() =>
|
const response = await this.queue.run(() => {
|
||||||
this.request<Response>(token, client, method, request),
|
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', {
|
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
@@ -508,34 +545,27 @@ export class TbankClient {
|
|||||||
const failureContext = queueFull
|
const failureContext = queueFull
|
||||||
? { code: TbankErrorCode.Unavailable }
|
? { code: TbankErrorCode.Unavailable }
|
||||||
: { grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull
|
const retryable = !queueFull && [
|
||||||
? metadataCall
|
status.DEADLINE_EXCEEDED,
|
||||||
: [
|
status.RESOURCE_EXHAUSTED,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.UNAVAILABLE,
|
||||||
status.RESOURCE_EXHAUSTED,
|
].includes(grpcCode);
|
||||||
status.UNAVAILABLE,
|
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||||
].includes(grpcCode);
|
|
||||||
if (retryable && attempts < maxAttempts) {
|
|
||||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts,
|
attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
await new Promise((resolve) =>
|
await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
|
||||||
setTimeout(
|
|
||||||
resolve,
|
|
||||||
metadataCall
|
|
||||||
? attempts * METADATA_RETRY_DELAY_MS
|
|
||||||
: RPC_RETRY_DELAY_MS,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts,
|
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
@@ -561,17 +591,42 @@ export class TbankClient {
|
|||||||
client: TbankClientService,
|
client: TbankClientService,
|
||||||
method: string,
|
method: string,
|
||||||
request: object,
|
request: object,
|
||||||
|
diagnostics?: RpcDiagnostics,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const metadata = new Metadata();
|
const metadata = new Metadata();
|
||||||
metadata.set('Authorization', `Bearer ${token}`);
|
metadata.set('Authorization', `Bearer ${token}`);
|
||||||
|
// 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.
|
// 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,
|
request as never,
|
||||||
metadata,
|
metadata,
|
||||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
{ 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
|
|||||||
|
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
import { HttpExceptionFilter } from './common/filters/http-exception.filter';
|
||||||
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
|
|
||||||
import { StructuredLogger } from './common/logging/structured-logger.service';
|
import { StructuredLogger } from './common/logging/structured-logger.service';
|
||||||
import { Environment } from './config/env.validation';
|
import { Environment } from './config/env.validation';
|
||||||
import { shouldExposeSwagger } from './config/swagger.config';
|
import { shouldExposeSwagger } from './config/swagger.config';
|
||||||
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
|
|||||||
});
|
});
|
||||||
app.setGlobalPrefix(API_PREFIX);
|
app.setGlobalPrefix(API_PREFIX);
|
||||||
app.use(cookieParser());
|
app.use(cookieParser());
|
||||||
const requestLogging = app.get(RequestLoggingMiddleware);
|
|
||||||
app.use(requestLogging.use.bind(requestLogging));
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
forbidUnknownValues: true,
|
forbidUnknownValues: true,
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { HttpErrorCode } from '../../../common/error-code';
|
|||||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
import { AuthErrorCode } from './auth-error-code';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@ export class AuthService {
|
|||||||
async register(
|
async register(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -64,7 +64,7 @@ export class AuthService {
|
|||||||
async login(
|
async login(
|
||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.prisma.user.findUnique({
|
||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
@@ -80,7 +80,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async refresh(
|
async refresh(
|
||||||
token: string,
|
token: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
let payload: RefreshPayload;
|
let payload: RefreshPayload;
|
||||||
try {
|
try {
|
||||||
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
payload = await this.jwt.verifyAsync<RefreshPayload>(token, {
|
||||||
@@ -151,7 +151,7 @@ export class AuthService {
|
|||||||
|
|
||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.prisma.session.create({
|
||||||
data: {
|
data: {
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -166,7 +166,7 @@ export class AuthService {
|
|||||||
private async rotateSession(
|
private async rotateSession(
|
||||||
session: Session & { user: User },
|
session: Session & { user: User },
|
||||||
expectedRefreshTokenHash?: string,
|
expectedRefreshTokenHash?: string,
|
||||||
): Promise<{ accessToken: string; refreshToken: string; user: AuthUser }> {
|
): Promise<AuthenticationResult> {
|
||||||
const refreshToken = await this.jwt.signAsync(
|
const refreshToken = await this.jwt.signAsync(
|
||||||
{
|
{
|
||||||
sub: session.userId,
|
sub: session.userId,
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface AuthUser {
|
|||||||
id: string;
|
id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AuthenticationResult = {
|
||||||
|
accessToken: string;
|
||||||
|
refreshToken: string;
|
||||||
|
user: AuthUser;
|
||||||
|
};
|
||||||
|
|
||||||
export interface AccessPayload {
|
export interface AccessPayload {
|
||||||
sid: string;
|
sid: string;
|
||||||
sub: string;
|
sub: string;
|
||||||
|
|||||||
@@ -23,8 +23,8 @@ import {
|
|||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { LoginDto, RegisterDto } from './auth.dto';
|
||||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||||
|
|
||||||
const REFRESH_COOKIE = 'refresh_token';
|
const REFRESH_COOKIE = 'refresh_token';
|
||||||
@@ -56,7 +56,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async register(
|
async register(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: RegisterDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
@@ -69,7 +69,7 @@ export class AuthController {
|
|||||||
@HttpCode(HttpStatus.OK)
|
@HttpCode(HttpStatus.OK)
|
||||||
@ApiOkResponse({ type: AuthenticationDto })
|
@ApiOkResponse({ type: AuthenticationDto })
|
||||||
async login(
|
async login(
|
||||||
@Body() credentials: CredentialsDto,
|
@Body() credentials: LoginDto,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
return this.respond(
|
return this.respond(
|
||||||
@@ -85,14 +85,9 @@ export class AuthController {
|
|||||||
@Req() request: Request,
|
@Req() request: Request,
|
||||||
@Res({ passthrough: true }) response: Response,
|
@Res({ passthrough: true }) response: Response,
|
||||||
) {
|
) {
|
||||||
try {
|
const token = this.refreshToken(request);
|
||||||
const token = this.refreshToken(request);
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
@@ -117,11 +112,7 @@ export class AuthController {
|
|||||||
|
|
||||||
private respond(
|
private respond(
|
||||||
response: Response,
|
response: Response,
|
||||||
result: {
|
result: AuthenticationResult,
|
||||||
accessToken: string;
|
|
||||||
refreshToken: string;
|
|
||||||
user: { email: string; id: string };
|
|
||||||
},
|
|
||||||
) {
|
) {
|
||||||
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
response.cookie(REFRESH_COOKIE, result.refreshToken, this.cookieOptions());
|
||||||
return { user: result.user, accessToken: result.accessToken };
|
return { user: result.user, accessToken: result.accessToken };
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
|
IsNotEmpty,
|
||||||
IsString,
|
IsString,
|
||||||
MinLength,
|
Matches,
|
||||||
Validate,
|
Validate,
|
||||||
ValidatorConstraint,
|
ValidatorConstraint,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
|
|||||||
const MAX_EMAIL_LENGTH = 254;
|
const MAX_EMAIL_LENGTH = 254;
|
||||||
const ASCII_SPACE = 32;
|
const ASCII_SPACE = 32;
|
||||||
const ASCII_DELETE = 127;
|
const ASCII_DELETE = 127;
|
||||||
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 MAX_PASSWORD_BYTES = 72;
|
||||||
|
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'email' })
|
@ValidatorConstraint({ name: 'email' })
|
||||||
class EmailConstraint {
|
class EmailConstraint {
|
||||||
@@ -40,8 +43,9 @@ class EmailConstraint {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||||
class PasswordBytesConstraint {
|
@ValidatorConstraint({ name: 'passwordMaxBytes' })
|
||||||
|
class PasswordMaxBytesConstraint {
|
||||||
validate(value: unknown): boolean {
|
validate(value: unknown): boolean {
|
||||||
return (
|
return (
|
||||||
typeof value === 'string' &&
|
typeof value === 'string' &&
|
||||||
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultMessage(): string {
|
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' })
|
@ApiProperty({ example: 'user@example.com' })
|
||||||
@IsString()
|
@IsString()
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
@@ -62,14 +66,28 @@ export class CredentialsDto {
|
|||||||
)
|
)
|
||||||
@Validate(EmailConstraint)
|
@Validate(EmailConstraint)
|
||||||
declare email: string;
|
declare email: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RegisterDto extends EmailDto {
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
minLength: MIN_PASSWORD_LENGTH,
|
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
|
||||||
maxLength: MAX_PASSWORD_BYTES,
|
pattern: PASSWORD_PATTERN,
|
||||||
example: 'a secure password',
|
example: 'a secure password',
|
||||||
})
|
})
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_PASSWORD_LENGTH)
|
@Matches(new RegExp(PASSWORD_PATTERN), {
|
||||||
@Validate(PasswordBytesConstraint)
|
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;
|
declare password: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
import { ExecutionContext, Injectable } from '@nestjs/common';
|
import { ExecutionContext, Injectable } from '@nestjs/common';
|
||||||
import { AuthGuard } from '@nestjs/passport';
|
import { AuthGuard } from '@nestjs/passport';
|
||||||
|
import { PinoLogger } from 'nestjs-pino';
|
||||||
|
|
||||||
import { RequestContextService } from '../../../common/logging/request-context.service';
|
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class JwtAuthGuard extends AuthGuard('jwt') {
|
export class JwtAuthGuard extends AuthGuard('jwt') {
|
||||||
constructor(private readonly requestContext: RequestContextService) {
|
constructor(private readonly logger: PinoLogger) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
|
|||||||
.switchToHttp()
|
.switchToHttp()
|
||||||
.getRequest<{ user?: AccessPayload }>();
|
.getRequest<{ user?: AccessPayload }>();
|
||||||
if (request.user?.sub) {
|
if (request.user?.sub) {
|
||||||
this.requestContext.set({ userId: request.user.sub });
|
this.logger.assign({ userId: request.user.sub });
|
||||||
}
|
}
|
||||||
return Boolean(allowed);
|
return Boolean(allowed);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,13 +103,9 @@ export class BankCardsService {
|
|||||||
id,
|
id,
|
||||||
name: dto.name ?? previous.name,
|
name: dto.name ?? previous.name,
|
||||||
bankName:
|
bankName:
|
||||||
dto.bankName === undefined
|
dto.bankName === undefined ? previous.bankName : dto.bankName,
|
||||||
? (previous.bankName ?? undefined)
|
|
||||||
: (dto.bankName ?? undefined),
|
|
||||||
lastFour:
|
lastFour:
|
||||||
dto.lastFour === undefined
|
dto.lastFour === undefined ? previous.lastFour : dto.lastFour,
|
||||||
? (previous.lastFour ?? undefined)
|
|
||||||
: (dto.lastFour ?? undefined),
|
|
||||||
paymentSystem:
|
paymentSystem:
|
||||||
dto.paymentSystem === undefined
|
dto.paymentSystem === undefined
|
||||||
? previous.paymentSystem
|
? previous.paymentSystem
|
||||||
|
|||||||
@@ -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(
|
private async validateOperation(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
userId: string,
|
userId: string,
|
||||||
@@ -359,9 +454,7 @@ export class BankOperationsService {
|
|||||||
take: limit,
|
take: limit,
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
items: await Promise.all(
|
items: await this.pageViews(tx, rows),
|
||||||
rows.map((row) => this.operationView(tx, row)),
|
|
||||||
),
|
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
hasNext: page * limit < total,
|
hasNext: page * limit < total,
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ export class BankCardsRepository {
|
|||||||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) {
|
if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
|
|||||||
|
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '../../../common/money.dto';
|
||||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
|
|||||||
|
|
||||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: HourlySyncStatusDto,
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
|
||||||
|
})
|
||||||
|
declare sync: HourlySyncStatusDto | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { BrokerageAccountsService } from './brokerage-accounts.service';
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import type { Request } from 'express';
|
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 { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||||
list(
|
list(@Req() request: Request & { user: AccessPayload }) {
|
||||||
@Req() request: Request & { user: AccessPayload },
|
return this.accounts.list(request.user.sub);
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
|
||||||
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,12 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
|
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule, PortfolioModule],
|
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||||
controllers: [BrokerageAccountsController],
|
controllers: [BrokerageAccountsController],
|
||||||
providers: [BrokerageAccountsService],
|
providers: [BrokerageAccountsService],
|
||||||
exports: [BrokerageAccountsService],
|
exports: [BrokerageAccountsService],
|
||||||
|
|||||||
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
|
|||||||
|
|
||||||
describe('saved brokerage account projection', () => {
|
describe('saved brokerage account projection', () => {
|
||||||
const portfolio = { saved: jest.fn() };
|
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 () => {
|
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: { ...meta, availability: 'missing' },
|
membership: { ...meta, completeness: 'incomplete' },
|
||||||
accounts: [],
|
accounts: [],
|
||||||
});
|
});
|
||||||
expect((await service.list('owner', true)).data).toBeNull();
|
expect((await service.list('owner')).data).toBeNull();
|
||||||
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
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 () => {
|
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({
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
});
|
});
|
||||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
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,16 +3,20 @@ import { createHash } from 'node:crypto';
|
|||||||
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
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()
|
@Injectable()
|
||||||
export class BrokerageAccountsService {
|
export class BrokerageAccountsService {
|
||||||
constructor(private readonly portfolio: PortfolioService) {}
|
constructor(
|
||||||
|
private readonly portfolio: PortfolioService,
|
||||||
|
private readonly sync: TbankSyncRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
async list(userId: string, observe = false) {
|
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
|
||||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
const saved = await this.portfolio.saved(userId, 'all');
|
||||||
const data: BrokerageAccountDto[] | null =
|
const data: BrokerageAccountDto[] | null =
|
||||||
saved.membership.availability === 'missing'
|
saved.membership.completeness === 'incomplete'
|
||||||
? null
|
? null
|
||||||
: saved.accounts.map((account) => ({
|
: saved.accounts.map((account) => ({
|
||||||
id: account.id,
|
id: account.id,
|
||||||
@@ -31,6 +35,8 @@ export class BrokerageAccountsService {
|
|||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
}));
|
}));
|
||||||
|
// The read-only result of the last hourly walk; never starts one.
|
||||||
|
const hourly = await this.sync.hourly(userId);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
@@ -49,18 +55,26 @@ export class BrokerageAccountsService {
|
|||||||
availability:
|
availability:
|
||||||
data === null
|
data === null
|
||||||
? 'missing'
|
? 'missing'
|
||||||
: saved.accounts.some((account) => !account.snapshot)
|
: 'complete',
|
||||||
? 'partial'
|
|
||||||
: 'complete',
|
|
||||||
fetchedAt: saved.membership.lastSuccessfulAt
|
fetchedAt: saved.membership.lastSuccessfulAt
|
||||||
? new Date(saved.membership.lastSuccessfulAt)
|
? new Date(saved.membership.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: 300_000,
|
freshForMs: 3_600_000,
|
||||||
work: saved.membership.sync.id
|
work: null,
|
||||||
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
|
||||||
: null,
|
|
||||||
dependencies: saved.accounts.map((account) => account.meta),
|
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;
|
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 {
|
export class IncomeBucketDto {
|
||||||
@ApiProperty({ type: IncomePeriodDto })
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
declare period: IncomePeriodDto;
|
declare period: IncomePeriodDto;
|
||||||
@@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeAnalyticsDto extends IncomeSummaryDto {
|
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'] })
|
@ApiProperty({ enum: ['day', 'week', 'month'] })
|
||||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||||
|
|
||||||
@@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto {
|
|||||||
declare meta: InvestmentResourceMetaDto;
|
declare meta: InvestmentResourceMetaDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
const incomeDependencyRoles = ['selected'] as const;
|
||||||
|
|
||||||
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||||
@ApiProperty({ enum: incomeDependencyRoles })
|
@ApiProperty({ enum: incomeDependencyRoles })
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
beforeEach(() => jest.clearAllMocks());
|
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([]);
|
seed([]);
|
||||||
const response = await service.analytics(
|
const response = await service.analytics(
|
||||||
'user',
|
'user',
|
||||||
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
);
|
);
|
||||||
expect(response.meta.dependencies).toEqual([
|
expect(response.meta.dependencies).toEqual([
|
||||||
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||||
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
|
||||||
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
|
||||||
]);
|
]);
|
||||||
});
|
expect(
|
||||||
|
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
|
||||||
it('labels an unavailable leap-day comparison as year', async () => {
|
).toBe(false);
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function seed(items: BrokerageOperationDto[]) {
|
function seed(items: BrokerageOperationDto[]) {
|
||||||
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
build: (opened?: Date) => unknown[],
|
build: (opened?: Date) => unknown[],
|
||||||
) => {
|
) => {
|
||||||
build(undefined);
|
build(undefined);
|
||||||
return [items, [], []].map((items, index) => ({
|
return [items].map((items, index) => ({
|
||||||
items,
|
items,
|
||||||
meta: completeMeta(index),
|
meta: completeMeta(index),
|
||||||
}));
|
}));
|
||||||
@@ -189,6 +167,49 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
).toEqual(['10.25', '-0.25']);
|
).toEqual(['10.25', '-0.25']);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
|
||||||
|
seed([
|
||||||
|
{
|
||||||
|
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
|
||||||
|
occurredAt: '2026-01-14T21:00:00.000Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
|
||||||
|
occurredAt: '2026-01-15T20:59:59.999Z',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const response = await service.analytics(
|
||||||
|
'user',
|
||||||
|
'account',
|
||||||
|
'2026-01-14',
|
||||||
|
'2026-01-16',
|
||||||
|
);
|
||||||
|
expect(response.data?.buckets).toEqual([
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||||
|
totalsByCurrency: [expect.objectContaining({
|
||||||
|
coupon: { currency: 'RUB', value: '0.2' },
|
||||||
|
dividend: { currency: 'RUB', value: '-0.1' },
|
||||||
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
|
})],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||||
|
totalsByCurrency: [],
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
@@ -204,8 +225,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||||
],
|
],
|
||||||
[],
|
|
||||||
[],
|
|
||||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import {
|
|||||||
compare,
|
compare,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
@@ -18,7 +17,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeComparisonDto,
|
|
||||||
IncomeCurrencySummaryDto,
|
IncomeCurrencySummaryDto,
|
||||||
IncomeDependencyDto,
|
IncomeDependencyDto,
|
||||||
IncomeInstrumentDto,
|
IncomeInstrumentDto,
|
||||||
@@ -47,14 +45,11 @@ export class IncomeAnalyticsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
observe = false,
|
observe = false,
|
||||||
): Promise<IncomeSummaryResourceDto> {
|
): Promise<IncomeSummaryResourceDto> {
|
||||||
let plan = planIncomePeriod(undefined, undefined);
|
const plan = planIncomePeriod(undefined, undefined);
|
||||||
const [selected] = await this.operations.collectRanges(
|
const [selected] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
() => [plan.selected],
|
||||||
plan = planIncomePeriod(undefined, undefined, openedAt);
|
|
||||||
return [plan.selected];
|
|
||||||
},
|
|
||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||||
@@ -77,54 +72,33 @@ export class IncomeAnalyticsService {
|
|||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
observe = false,
|
observe = false,
|
||||||
): Promise<IncomeAnalyticsResourceDto> {
|
): Promise<IncomeAnalyticsResourceDto> {
|
||||||
let plan = planIncomePeriod(from, to);
|
const plan = planIncomePeriod(from, to);
|
||||||
const periods = await this.operations.collectRanges(
|
const [current] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
(openedAt) => {
|
() => [plan.selected],
|
||||||
plan = planIncomePeriod(from, to, openedAt);
|
|
||||||
return [
|
|
||||||
plan.selected,
|
|
||||||
plan.previous,
|
|
||||||
...(plan.yearAgo ? [plan.yearAgo] : []),
|
|
||||||
];
|
|
||||||
},
|
|
||||||
observe,
|
observe,
|
||||||
);
|
);
|
||||||
const [current, prior, year] = periods;
|
|
||||||
// Preserve the legacy order while consumers migrate to explicit roles.
|
|
||||||
const dependencies: IncomeDependencyDto[] = [
|
const dependencies: IncomeDependencyDto[] = [
|
||||||
{ ...current.meta, role: 'selected' },
|
{ ...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 = current.meta.sync;
|
||||||
const work =
|
|
||||||
states.find((state) => state.state === 'running') ??
|
|
||||||
states.find((state) => state.state === 'failed');
|
|
||||||
const meta = {
|
const meta = {
|
||||||
...investmentResourceMeta({
|
...investmentResourceMeta({
|
||||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||||
dataRevision: current.meta.dataRevision,
|
dataRevision: current.meta.dataRevision,
|
||||||
availability: current.meta.availability,
|
availability:
|
||||||
|
current.items === null
|
||||||
|
? 'temporarily-unavailable'
|
||||||
|
: 'available',
|
||||||
fetchedAt: current.meta.lastSuccessfulAt
|
fetchedAt: current.meta.lastSuccessfulAt
|
||||||
? new Date(current.meta.lastSuccessfulAt)
|
? new Date(current.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work.id
|
||||||
|
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
||||||
|
: null,
|
||||||
|
lastAttempt: current.meta.lastAttempt,
|
||||||
coverage: current.meta.coverage,
|
coverage: current.meta.coverage,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
@@ -144,14 +118,6 @@ export class IncomeAnalyticsService {
|
|||||||
selected,
|
selected,
|
||||||
current.meta.lastSuccessfulAt,
|
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,
|
bucketGranularity: plan.granularity,
|
||||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||||
instruments: instruments(selected),
|
instruments: instruments(selected),
|
||||||
@@ -195,50 +161,21 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
|||||||
.map(([currency, total]) => presentTotal(currency, total));
|
.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(
|
function buckets(
|
||||||
range: DateOnlyRange,
|
range: DateOnlyRange,
|
||||||
granularity: IncomeBucketGranularity,
|
granularity: IncomeBucketGranularity,
|
||||||
entries: BrokerageOperationDto[],
|
entries: BrokerageOperationDto[],
|
||||||
) {
|
) {
|
||||||
|
const datedEntries = entries.map((entry) => ({
|
||||||
|
entry,
|
||||||
|
date: moscowDate(new Date(entry.occurredAt)),
|
||||||
|
}));
|
||||||
return rangeBuckets(range, granularity).map((period) => ({
|
return rangeBuckets(range, granularity).map((period) => ({
|
||||||
period,
|
period,
|
||||||
totalsByCurrency: totals(
|
totalsByCurrency: totals(
|
||||||
entries.filter((entry) => {
|
datedEntries
|
||||||
const date = moscowDate(new Date(entry.occurredAt));
|
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||||
return date >= period.from && date <= period.to;
|
.map(({ entry }) => entry),
|
||||||
}),
|
|
||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -287,22 +224,6 @@ function instrumentKey(entry: BrokerageOperationDto): string {
|
|||||||
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
|
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 {
|
function zeroTotal(): Total {
|
||||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
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', () => {
|
it('keeps empty buckets and skips removed comparison ranges', () => {
|
||||||
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();
|
|
||||||
expect(
|
expect(
|
||||||
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
||||||
).toHaveLength(7);
|
).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', () => {
|
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.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
|
||||||
expect(plan.granularity).toBe('month');
|
expect(plan.granularity).toBe('month');
|
||||||
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
|
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', () => {
|
it('does not send the unfinished Moscow day as a future instant', () => {
|
||||||
|
|||||||
@@ -14,45 +14,28 @@ const MONDAY_WEEKDAY_INDEX = 1;
|
|||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
const INCLUSIVE_DAY_OFFSET = 1;
|
||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||||
const PREVIOUS_DAY_OFFSET = -1;
|
|
||||||
const PREVIOUS_YEAR_OFFSET = 1;
|
|
||||||
const DATE_COMPONENT_DIGITS = 2;
|
|
||||||
|
|
||||||
const DAILY_BUCKET_MAX_DAYS = 45;
|
const DAILY_BUCKET_MAX_DAYS = 45;
|
||||||
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
const WEEKLY_BUCKET_MAX_DAYS = 180;
|
||||||
const DAYS_PER_WEEK = 7;
|
const DAYS_PER_WEEK = 7;
|
||||||
const ISO_MONTH_LENGTH = 7;
|
const ISO_MONTH_LENGTH = 7;
|
||||||
const ISO_YEAR_LENGTH = 4;
|
|
||||||
|
|
||||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
||||||
|
|
||||||
export function planIncomePeriod(
|
export function planIncomePeriod(
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
openedDate?: Date,
|
|
||||||
): {
|
): {
|
||||||
granularity: IncomeBucketGranularity;
|
granularity: IncomeBucketGranularity;
|
||||||
previous: DateOnlyRange;
|
|
||||||
selected: DateOnlyRange;
|
selected: DateOnlyRange;
|
||||||
yearAgo: DateOnlyRange | null;
|
|
||||||
} {
|
} {
|
||||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||||
const today = moscowDate(new Date());
|
const today = moscowDate(new Date());
|
||||||
const selected =
|
const selected =
|
||||||
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
from && to ? { from, to } : { from: firstOfMonth(today), to: today };
|
||||||
assertRange(selected, today);
|
assertRange(selected, today);
|
||||||
const previous = isFullMonth(selected)
|
|
||||||
? previousMonth(selected.from)
|
|
||||||
: shiftDays(selected, -rangeDays(selected));
|
|
||||||
const candidate = yearAgo(selected);
|
|
||||||
const yearAgoRange =
|
|
||||||
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
|
|
||||||
? candidate
|
|
||||||
: null;
|
|
||||||
return {
|
return {
|
||||||
selected,
|
selected,
|
||||||
previous,
|
|
||||||
yearAgo: yearAgoRange,
|
|
||||||
granularity:
|
granularity:
|
||||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||||
? 'day'
|
? 'day'
|
||||||
@@ -136,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);
|
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 {
|
function invalidPeriod(): BadRequestException {
|
||||||
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { IncomeAnalyticsService } from './income-analytics.service';
|
|
||||||
@@ -5,10 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -17,7 +15,6 @@ import {
|
|||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { planInvestmentScreen } from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
candleGaps,
|
candleGaps,
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
@@ -26,13 +23,13 @@ import {
|
|||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
|
||||||
const BACKGROUND_SYNC_PRIORITY = 2;
|
|
||||||
const REPLACED_WINDOW_COUNT = 1;
|
const REPLACED_WINDOW_COUNT = 1;
|
||||||
const NEXT_WINDOW_OFFSET = 1;
|
const NEXT_WINDOW_OFFSET = 1;
|
||||||
|
const NO_CANDLE_GAPS = 0;
|
||||||
|
|
||||||
type CandleInput = {
|
type CandleInput = {
|
||||||
instrumentType: 'bond' | 'share';
|
instrumentType: 'bond' | 'share';
|
||||||
@@ -56,7 +53,6 @@ type Producer = {
|
|||||||
};
|
};
|
||||||
const months = { month: 1, halfYear: 6, year: 12 };
|
const months = { month: 1, halfYear: 6, year: 12 };
|
||||||
const tailFreshForMs = 60_000;
|
const tailFreshForMs = 60_000;
|
||||||
const prefetchReadBatchSize = 100;
|
|
||||||
function scope(input: CandleInput) {
|
function scope(input: CandleInput) {
|
||||||
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
|
||||||
}
|
}
|
||||||
@@ -94,7 +90,6 @@ export class CandleService {
|
|||||||
private readonly queue: Producer[] = [];
|
private readonly queue: Producer[] = [];
|
||||||
private reservations: Promise<unknown> = Promise.resolve();
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
private running = false;
|
private running = false;
|
||||||
private readonly prefetches = new Map<string, Promise<void>>();
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CandleRepository,
|
private readonly repository: CandleRepository,
|
||||||
private readonly sources: CatalogEnrichmentService,
|
private readonly sources: CatalogEnrichmentService,
|
||||||
@@ -112,7 +107,7 @@ export class CandleService {
|
|||||||
true,
|
true,
|
||||||
['detail'],
|
['detail'],
|
||||||
);
|
);
|
||||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
if (detail.meta.completeness === 'complete' && !detail.instrument) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
@@ -120,8 +115,10 @@ export class CandleService {
|
|||||||
? rangeFor(input, detail.instrument, now)
|
? rangeFor(input, detail.instrument, now)
|
||||||
: null;
|
: null;
|
||||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
const hasCompleteRange = Boolean(
|
||||||
if (!observe && (!saved || gaps.length)) {
|
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||||
|
);
|
||||||
|
if (!observe && (!saved || !hasCompleteRange)) {
|
||||||
await this.request(userId, revision, input, now);
|
await this.request(userId, revision, input, now);
|
||||||
}
|
}
|
||||||
const work = await this.repository.work(userId, revision, scope(input));
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
@@ -206,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(
|
request(
|
||||||
userId: string,
|
userId: string,
|
||||||
revision: string,
|
revision: string,
|
||||||
@@ -413,17 +306,7 @@ export class CandleService {
|
|||||||
window.from,
|
window.from,
|
||||||
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
new Date(Math.min(window.to.valueOf(), now.valueOf())),
|
||||||
);
|
);
|
||||||
const rows = candles.flatMap((candle) => {
|
const rows = candles.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) throw new Error('Invalid candle response');
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
});
|
|
||||||
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
if (!(await this.repository.stage(attempt, window, rows, now))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -510,17 +393,6 @@ export class CandleService {
|
|||||||
this.reservations = finished.catch(() => undefined);
|
this.reservations = finished.catch(() => undefined);
|
||||||
if (!(await finished)) {
|
if (!(await finished)) {
|
||||||
this.queue.push(producer);
|
this.queue.push(producer);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (success && !producer.force && producer.input.period !== 'all') {
|
|
||||||
await this.request(
|
|
||||||
producer.attempt.userId,
|
|
||||||
producer.attempt.revision,
|
|
||||||
{ ...producer.input, period: 'all' },
|
|
||||||
producer.now,
|
|
||||||
false,
|
|
||||||
BACKGROUND_SYNC_PRIORITY,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -3,16 +3,11 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
tbankSourceNeedsRefresh,
|
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
@@ -27,6 +22,7 @@ import {
|
|||||||
catalogSearchLimit,
|
catalogSearchLimit,
|
||||||
selectCatalogInstruments,
|
selectCatalogInstruments,
|
||||||
} from './catalog-selection';
|
} from './catalog-selection';
|
||||||
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||||
const NO_MATCHES = 0;
|
const NO_MATCHES = 0;
|
||||||
@@ -34,38 +30,19 @@ const FIRST_RESULT_OFFSET = 0;
|
|||||||
|
|
||||||
const tickerAmbiguityLimit = 2;
|
const tickerAmbiguityLimit = 2;
|
||||||
|
|
||||||
type Producer = {
|
|
||||||
attempt: TbankSyncAttempt;
|
|
||||||
force: boolean;
|
|
||||||
input: CatalogQuery;
|
|
||||||
observers: TbankSyncAttempt[];
|
|
||||||
};
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogDiscoveryService {
|
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(
|
constructor(
|
||||||
private readonly repository: CatalogDiscoveryRepository,
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
|
||||||
private readonly enrichment: CatalogEnrichmentService,
|
private readonly enrichment: CatalogEnrichmentService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(userId: string, input: CatalogQuery, observe = false) {
|
async read(userId: string, input: CatalogQuery) {
|
||||||
input = catalogQuery(input);
|
input = catalogQuery(input);
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const saved = await this.repository.read(input);
|
const saved = await this.repository.read(input);
|
||||||
if (!observe) await this.request(userId, connection.revision, input);
|
|
||||||
return {
|
return {
|
||||||
data: saved.data,
|
data: saved.data,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
@@ -73,14 +50,15 @@ export class CatalogDiscoveryService {
|
|||||||
dataRevision: saved.dataRevision,
|
dataRevision: saved.dataRevision,
|
||||||
availability: saved.data === null ? 'missing' : 'complete',
|
availability: saved.data === null ? 'missing' : 'complete',
|
||||||
fetchedAt: saved.fetchedAt,
|
fetchedAt: saved.fetchedAt,
|
||||||
freshForMs: tbankSourceTtlMs.catalog,
|
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||||
work: await this.repository.work(userId, connection.revision, input),
|
work: await this.repository.work(userId, connection.revision, input),
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||||
const discovery = await this.read(userId, input, observe);
|
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
||||||
|
const discovery = await this.read(userId, input);
|
||||||
const sources = discovery.data
|
const sources = discovery.data
|
||||||
? await this.enrichment.readMany(
|
? await this.enrichment.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -118,13 +96,13 @@ export class CatalogDiscoveryService {
|
|||||||
availability:
|
availability:
|
||||||
discovery.data === null
|
discovery.data === null
|
||||||
? 'missing'
|
? 'missing'
|
||||||
: sources.every((source) => source.meta.availability === 'complete')
|
: sources.every((source) => source.meta.completeness === 'complete')
|
||||||
? 'complete'
|
? 'complete'
|
||||||
: 'partial',
|
: 'partial',
|
||||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||||
? new Date(discovery.meta.lastSuccessfulAt)
|
? new Date(discovery.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.catalog,
|
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||||
work: discovery.meta.sync.id
|
work: discovery.meta.sync.id
|
||||||
? {
|
? {
|
||||||
id: discovery.meta.sync.id,
|
id: discovery.meta.sync.id,
|
||||||
@@ -143,6 +121,25 @@ export class CatalogDiscoveryService {
|
|||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
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(
|
const sources = await this.enrichment.readMany(
|
||||||
userId,
|
userId,
|
||||||
instrumentType,
|
instrumentType,
|
||||||
@@ -151,7 +148,7 @@ export class CatalogDiscoveryService {
|
|||||||
['detail'],
|
['detail'],
|
||||||
);
|
);
|
||||||
const source = sources[0];
|
const source = sources[0];
|
||||||
if (source.meta.availability === 'complete' && !source.instrument) {
|
if (source.meta.completeness === 'complete' && !source.instrument) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const instrument = source.instrument;
|
const instrument = source.instrument;
|
||||||
@@ -161,11 +158,11 @@ export class CatalogDiscoveryService {
|
|||||||
instrument?.ticker &&
|
instrument?.ticker &&
|
||||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
||||||
) {
|
) {
|
||||||
const alias = await this.read(
|
const alias = await this.read(userId, {
|
||||||
userId,
|
instrumentType,
|
||||||
{ instrumentType, query: instrument.ticker, exactTicker: true },
|
query: instrument.ticker,
|
||||||
observe,
|
exactTicker: true,
|
||||||
);
|
});
|
||||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
||||||
slug = instrument.ticker.toUpperCase();
|
slug = instrument.ticker.toUpperCase();
|
||||||
}
|
}
|
||||||
@@ -182,7 +179,7 @@ export class CatalogDiscoveryService {
|
|||||||
fetchedAt: source.meta.lastSuccessfulAt
|
fetchedAt: source.meta.lastSuccessfulAt
|
||||||
? new Date(source.meta.lastSuccessfulAt)
|
? new Date(source.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.portfolio,
|
||||||
work: source.meta.sync.id
|
work: source.meta.sync.id
|
||||||
? {
|
? {
|
||||||
id: source.meta.sync.id,
|
id: source.meta.sync.id,
|
||||||
@@ -195,17 +192,42 @@ export class CatalogDiscoveryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
||||||
async byTicker(
|
async byTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
observe = false,
|
||||||
) {
|
) {
|
||||||
const lookup = await this.read(
|
if (!observe) {
|
||||||
userId,
|
const query = ticker.trim().toUpperCase();
|
||||||
{ instrumentType, query: ticker, exactTicker: true },
|
const found = await this.access.online(userId, ({ token }) =>
|
||||||
observe,
|
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?.length === NO_MATCHES) throw new NotFoundException();
|
||||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
@@ -232,7 +254,7 @@ export class CatalogDiscoveryService {
|
|||||||
fetchedAt: lookup.meta.lastSuccessfulAt
|
fetchedAt: lookup.meta.lastSuccessfulAt
|
||||||
? new Date(lookup.meta.lastSuccessfulAt)
|
? new Date(lookup.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.portfolio,
|
||||||
work: lookup.meta.sync.id
|
work: lookup.meta.sync.id
|
||||||
? {
|
? {
|
||||||
id: lookup.meta.sync.id,
|
id: lookup.meta.sync.id,
|
||||||
@@ -245,162 +267,52 @@ export class CatalogDiscoveryService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
request(
|
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||||
userId: string,
|
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
||||||
revision: string,
|
const found =
|
||||||
query: CatalogQuery,
|
input.instrumentType === 'share'
|
||||||
force = false,
|
? await this.tbank.findShares(token, input.query)
|
||||||
) {
|
: await this.tbank.findBonds(token, input.query);
|
||||||
const input = catalogQuery(query);
|
const instruments = selectCatalogInstruments(
|
||||||
const reserved = this.reservations.then(async () => {
|
input.exactTicker
|
||||||
const saved = await this.repository.read(input);
|
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
|
||||||
if (
|
: found,
|
||||||
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
input.query,
|
||||||
) {
|
input.instrumentType,
|
||||||
return null;
|
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||||
}
|
const [details, prices] = await Promise.all([
|
||||||
const reservation = await this.repository.reserve(
|
Promise.all(
|
||||||
{ userId, revision },
|
instruments.map((item) =>
|
||||||
input,
|
input.instrumentType === 'share'
|
||||||
this.runId,
|
? this.tbank.getShare(token, item.uid)
|
||||||
this.active,
|
: this.tbank.getBond(token, item.uid),
|
||||||
);
|
),
|
||||||
if (!reservation) return null;
|
),
|
||||||
const { id, attemptId } = reservation.work;
|
instruments.length
|
||||||
if (!reservation.launch) {
|
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
|
||||||
const current = this.producers.get(catalogScope(input));
|
: [],
|
||||||
if (current) current.force ||= force;
|
]);
|
||||||
return id;
|
return { details, instruments, prices };
|
||||||
}
|
|
||||||
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;
|
|
||||||
});
|
});
|
||||||
this.reservations = reserved.catch(() => undefined);
|
const fetchedAt = new Date();
|
||||||
return reserved;
|
const data = instruments.flatMap((item, index) => {
|
||||||
}
|
const detail = details.at(index);
|
||||||
|
return [{
|
||||||
async refresh(userId: string, revision: string, query: CatalogQuery) {
|
...item,
|
||||||
const id = await this.request(userId, revision, query, true);
|
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||||
if (id) {
|
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||||
await Promise.all(
|
}];
|
||||||
[...this.completions.values()]
|
});
|
||||||
.filter((entry) => entry.workId === id)
|
return {
|
||||||
.map((entry) => entry.done),
|
data,
|
||||||
);
|
meta: investmentResourceMeta({
|
||||||
}
|
resourceKey: `search:${catalogScope(input)}`,
|
||||||
const saved = await this.repository.read(catalogQuery(query));
|
dataRevision: `online:${fetchedAt.toISOString()}`,
|
||||||
const sources = await this.enrichment.readMany(
|
availability: 'complete',
|
||||||
userId,
|
fetchedAt,
|
||||||
query.instrumentType,
|
freshForMs: tbankSourceTtlMs.portfolio,
|
||||||
saved.data?.map((item) => item.uid) ?? [],
|
work: null,
|
||||||
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 && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
|
||||||
await this.access.rejectMembershipToken(attempt);
|
|
||||||
} else {
|
|
||||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
|
||||||
code: TbankErrorCode.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: TbankErrorCode.Unavailable,
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
this.running = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import {
|
import {
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import {
|
import {
|
||||||
@@ -13,7 +12,6 @@ import {
|
|||||||
} from '../../../../common/time';
|
} from '../../../../common/time';
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -37,8 +35,10 @@ import {
|
|||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
const FIRST_QUEUE_INDEX = 0;
|
const FIRST_QUEUE_INDEX = 0;
|
||||||
@@ -59,7 +59,7 @@ export class CatalogEnrichmentService {
|
|||||||
private running = false;
|
private running = false;
|
||||||
private readonly completions = new Map<
|
private readonly completions = new Map<
|
||||||
string,
|
string,
|
||||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
{ attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
|
||||||
>();
|
>();
|
||||||
|
|
||||||
async ensureDetail(
|
async ensureDetail(
|
||||||
@@ -155,7 +155,7 @@ export class CatalogEnrichmentService {
|
|||||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
availability: row ? 'complete' : 'missing',
|
availability: row ? 'complete' : 'missing',
|
||||||
fetchedAt: row?.fetchedAt ?? null,
|
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,
|
work: byScope.get(catalogScope(input)) ?? null,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -187,14 +187,10 @@ export class CatalogEnrichmentService {
|
|||||||
(entry) =>
|
(entry) =>
|
||||||
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||||
);
|
);
|
||||||
if (
|
const policy = sourcePolicy(input.kind);
|
||||||
!tbankSourceNeedsRefresh(
|
const needsRefresh = force || !row?.fetchedAt ||
|
||||||
sourcePolicy(input.kind),
|
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
||||||
row?.fetchedAt ?? null,
|
if (!needsRefresh) {
|
||||||
new Date(),
|
|
||||||
force,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const reserved = await this.repository.reserve(
|
const reserved = await this.repository.reserve(
|
||||||
@@ -213,7 +209,7 @@ export class CatalogEnrichmentService {
|
|||||||
const done = new Promise<void>((finish) => {
|
const done = new Promise<void>((finish) => {
|
||||||
resolve = 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 key = producerKey(input);
|
||||||
const producer = this.producers.get(key);
|
const producer = this.producers.get(key);
|
||||||
if (producer) producer.observers.push({ attempt, input });
|
if (producer) producer.observers.push({ attempt, input });
|
||||||
@@ -242,6 +238,36 @@ export class CatalogEnrichmentService {
|
|||||||
section: 'company' | 'metrics' | 'quote',
|
section: 'company' | 'metrics' | 'quote',
|
||||||
observe = false,
|
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'][] =
|
const kinds: CatalogSource['kind'][] =
|
||||||
section === 'company'
|
section === 'company'
|
||||||
? ['detail', 'description', 'fundamentals']
|
? ['detail', 'description', 'fundamentals']
|
||||||
@@ -264,7 +290,7 @@ export class CatalogEnrichmentService {
|
|||||||
kinds,
|
kinds,
|
||||||
);
|
);
|
||||||
const first = sources[0];
|
const first = sources[0];
|
||||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
if (first.meta.completeness === 'complete' && !first.instrument) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const values = sources.reduce<CatalogPayload>(
|
const values = sources.reduce<CatalogPayload>(
|
||||||
@@ -273,7 +299,7 @@ export class CatalogEnrichmentService {
|
|||||||
);
|
);
|
||||||
const dependencies = sources.map((source) => source.meta);
|
const dependencies = sources.map((source) => source.meta);
|
||||||
const complete = dependencies.every(
|
const complete = dependencies.every(
|
||||||
(dependency) => dependency.availability === 'complete',
|
(dependency) => dependency.completeness === 'complete',
|
||||||
);
|
);
|
||||||
const meta = investmentResourceMeta({
|
const meta = investmentResourceMeta({
|
||||||
resourceKey: `${instrumentType}/${section}/${uid}`,
|
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||||
@@ -288,10 +314,7 @@ export class CatalogEnrichmentService {
|
|||||||
fetchedAt: first.meta.lastSuccessfulAt
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
? new Date(first.meta.lastSuccessfulAt)
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs:
|
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
||||||
section === 'company'
|
|
||||||
? tbankSourceTtlMs.company
|
|
||||||
: tbankSourceTtlMs.quote,
|
|
||||||
work: null,
|
work: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
});
|
||||||
@@ -301,7 +324,7 @@ export class CatalogEnrichmentService {
|
|||||||
unavailable: sources
|
unavailable: sources
|
||||||
.filter(
|
.filter(
|
||||||
(source) =>
|
(source) =>
|
||||||
source.meta.availability !== 'complete' ||
|
source.meta.completeness !== 'complete' ||
|
||||||
source.meta.sync.state === 'failed',
|
source.meta.sync.state === 'failed',
|
||||||
)
|
)
|
||||||
.map((source) => source.input.kind),
|
.map((source) => source.input.kind),
|
||||||
@@ -309,6 +332,7 @@ export class CatalogEnrichmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||||
private async load(
|
private async load(
|
||||||
input: CatalogSource,
|
input: CatalogSource,
|
||||||
token: string,
|
token: string,
|
||||||
@@ -324,7 +348,7 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
// 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');
|
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') {
|
if (input.kind === 'trading-status') {
|
||||||
return {
|
return {
|
||||||
@@ -353,17 +377,7 @@ export class CatalogEnrichmentService {
|
|||||||
gap.from,
|
gap.from,
|
||||||
gap.to,
|
gap.to,
|
||||||
);
|
);
|
||||||
const rows = response.map((candle) => {
|
const rows = response.map(toSavedCandle);
|
||||||
const time = timestamp(candle.time);
|
|
||||||
if (!time || !candle.close) {
|
|
||||||
throw new Error('Invalid candle response');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
time,
|
|
||||||
close: formatDecimal(decimal(candle.close)),
|
|
||||||
isComplete: candle.isComplete === true,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||||
throw new Error('Cancelled candle comparison');
|
throw new Error('Cancelled candle comparison');
|
||||||
}
|
}
|
||||||
@@ -481,6 +495,7 @@ export class CatalogEnrichmentService {
|
|||||||
throw new Error('Unsupported catalog source');
|
throw new Error('Unsupported catalog source');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
||||||
private async drain() {
|
private async drain() {
|
||||||
try {
|
try {
|
||||||
while (this.queue.length) {
|
while (this.queue.length) {
|
||||||
@@ -567,6 +582,14 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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();
|
for (const completion of this.completions.values()) completion.resolve();
|
||||||
this.completions.clear();
|
this.completions.clear();
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
@@ -611,39 +634,6 @@ function sourceInputs(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 previousCloseLookbackDays = 20;
|
||||||
const previousCloseLookbackMs =
|
const previousCloseLookbackMs =
|
||||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||||
@@ -662,6 +652,7 @@ export type CatalogPayload = {
|
|||||||
price?: null | TbankLastPrice;
|
price?: null | TbankLastPrice;
|
||||||
tradingStatus?: null | string;
|
tradingStatus?: null | string;
|
||||||
};
|
};
|
||||||
|
|
||||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
if (
|
if (
|
||||||
[
|
[
|
||||||
@@ -674,9 +665,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
) {
|
) {
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
}
|
||||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
return 'stable';
|
||||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
|
||||||
return 'metadata';
|
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
return input.instrumentType === 'bond' &&
|
return input.instrumentType === 'bond' &&
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -70,8 +71,8 @@ export class BondIncomeService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async readPosition(userId: string, uid: string, observe = false) {
|
async readPosition(userId: string, uid: string) {
|
||||||
const saved = await this.portfolios.read(userId, 'all', observe);
|
const saved = await this.portfolios.read(userId, 'all');
|
||||||
return {
|
return {
|
||||||
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||||
@@ -87,7 +88,7 @@ export class BondIncomeService {
|
|||||||
const today = moscowDay(new Date().toISOString());
|
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 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 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(
|
const sources = await this.sources.readMany(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
@@ -96,7 +97,7 @@ export class BondIncomeService {
|
|||||||
['detail', 'coupons'],
|
['detail', 'coupons'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const events = sources[1].payload?.coupons;
|
const events = sources[1].payload?.coupons;
|
||||||
@@ -122,6 +123,10 @@ export class BondIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
|
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
|
return result.data || !projected
|
||||||
? result
|
? result
|
||||||
: {
|
: {
|
||||||
@@ -132,7 +137,7 @@ export class BondIncomeService {
|
|||||||
projected.observedAt,
|
projected.observedAt,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
meta: { ...result.meta, availability: 'partial' },
|
meta: { ...result.meta, completeness: 'incomplete' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -119,13 +119,8 @@ export class BondController {
|
|||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.readPosition(
|
return this.income.readPosition(request.user.sub, uid);
|
||||||
request.user.sub,
|
|
||||||
uid,
|
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/coupons')
|
@Get(':uid/coupons')
|
||||||
|
|||||||
@@ -53,12 +53,6 @@ export class CandleRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
coverage(uids: string[]) {
|
|
||||||
return this.prisma.tbankCandleCoverage.findMany({
|
|
||||||
where: { provider, instrumentUid: { in: uids }, interval },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
reserve(
|
reserve(
|
||||||
owner: { revision: string; userId: string },
|
owner: { revision: string; userId: string },
|
||||||
scopeKey: string,
|
scopeKey: string,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { toSavedCandle } from './tbank-candle.mapper';
|
||||||
|
|
||||||
|
describe('T-Bank candle mapping', () => {
|
||||||
|
it('keeps signed nano precision and requires explicit completeness', () => {
|
||||||
|
const input = { time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } };
|
||||||
|
expect(toSavedCandle(input)).toEqual({
|
||||||
|
time: '2026-01-01T00:00:00.000Z', close: '-0.000000001', isComplete: false,
|
||||||
|
});
|
||||||
|
expect(toSavedCandle({ ...input, isComplete: true }).isComplete).toBe(true);
|
||||||
|
expect(input).toEqual({ time: { seconds: '1767225600' }, close: { units: '0', nano: -1 } });
|
||||||
|
});
|
||||||
|
it.each([
|
||||||
|
{ close: { units: '1' } },
|
||||||
|
{ time: { seconds: 'invalid' }, close: { units: '1' } },
|
||||||
|
{ time: { seconds: '1767225600' } },
|
||||||
|
])('rejects missing or malformed source fields: %j', (input) => {
|
||||||
|
expect(() => toSavedCandle(input)).toThrow('Invalid candle response');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import type { SavedCandle } from '../domain/candle-coverage';
|
||||||
|
|
||||||
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { timestamp } from '../application/instrument-market';
|
||||||
|
|
||||||
|
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||||
|
const time = timestamp(candle.time);
|
||||||
|
if (!time || !candle.close) throw new Error('Invalid candle response');
|
||||||
|
return {
|
||||||
|
time,
|
||||||
|
close: formatDecimal(decimal(candle.close)),
|
||||||
|
isComplete: candle.isComplete === true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toSavedCandle };
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
export { BondIncomeService } from './bonds/application/bond-income.service';
|
|
||||||
export { StockIncomeService } from './stocks/application/stock-income.service';
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
|
|||||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||||
import { StockIncomeService } from './stocks/application/stock-income.service';
|
import { StockIncomeService } from './stocks/application/stock-income.service';
|
||||||
import { StockMarketService } from './stocks/application/stock-market.service';
|
import { StockMarketService } from './stocks/application/stock-market.service';
|
||||||
|
import { StockScreenerService } from './stocks/application/stock-screener.service';
|
||||||
import { StockController } from './stocks/presentation/stock.controller';
|
import { StockController } from './stocks/presentation/stock.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
exports: [StockIncomeService, BondIncomeService],
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
|
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
GetStockCompanyUseCase,
|
GetStockCompanyUseCase,
|
||||||
StockIncomeService,
|
StockIncomeService,
|
||||||
StockMarketService,
|
StockMarketService,
|
||||||
StockCatalogService,
|
StockCatalogService,
|
||||||
|
StockScreenerService,
|
||||||
BondCatalogService,
|
BondCatalogService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
|
|||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '../../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
@@ -39,8 +40,8 @@ export class StockIncomeService {
|
|||||||
private readonly forecasts: ForecastService,
|
private readonly forecasts: ForecastService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async readPosition(userId: string, uid: string, observe = false) {
|
async readPosition(userId: string, uid: string) {
|
||||||
const saved = await this.portfolios.read(userId, 'all', observe);
|
const saved = await this.portfolios.read(userId, 'all');
|
||||||
return {
|
return {
|
||||||
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
|
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
|
||||||
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
|
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
|
||||||
@@ -56,7 +57,7 @@ export class StockIncomeService {
|
|||||||
const today = moscowDay(new Date().toISOString());
|
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 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 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(
|
const sources = await this.sources.readMany(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
@@ -65,7 +66,7 @@ export class StockIncomeService {
|
|||||||
['detail', 'dividends'],
|
['detail', 'dividends'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
const events = sources[1].payload?.dividends;
|
const events = sources[1].payload?.dividends;
|
||||||
@@ -91,6 +92,10 @@ export class StockIncomeService {
|
|||||||
candidate:
|
candidate:
|
||||||
portfolio.data && sources[0].instrument ? projected : undefined,
|
portfolio.data && sources[0].instrument ? projected : undefined,
|
||||||
});
|
});
|
||||||
|
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
|
return result.data || !projected
|
||||||
? result
|
? result
|
||||||
: {
|
: {
|
||||||
@@ -101,7 +106,7 @@ export class StockIncomeService {
|
|||||||
projected.observedAt,
|
projected.observedAt,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
meta: { ...result.meta, availability: 'partial' },
|
meta: { ...result.meta, completeness: 'incomplete' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { StockScreenerService } from './stock-screener.service';
|
||||||
|
|
||||||
|
const token = 'secret-token';
|
||||||
|
const revision = 'revision-1';
|
||||||
|
const pageSize = 30;
|
||||||
|
|
||||||
|
function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
currency: 'RUB',
|
||||||
|
lot: 1,
|
||||||
|
name: `Share ${index}`,
|
||||||
|
ticker: `T${String(index).padStart(2, '0')}`,
|
||||||
|
uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('StockScreenerService', () => {
|
||||||
|
const access = { online: jest.fn(), local: jest.fn() };
|
||||||
|
const client = { getAllShares: jest.fn() };
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
let service: StockScreenerService;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.clearAllMocks();
|
||||||
|
access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise<unknown>) =>
|
||||||
|
run({ revision, token }),
|
||||||
|
);
|
||||||
|
access.local.mockResolvedValue({ revision });
|
||||||
|
client.getAllShares.mockResolvedValue(
|
||||||
|
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
||||||
|
);
|
||||||
|
service = new StockScreenerService(access, client as never, logger as never);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
||||||
|
const first = await service.search('user-1', {});
|
||||||
|
const second = await service.page('user-1', first.searchId, 2);
|
||||||
|
|
||||||
|
expect(first.items).toHaveLength(pageSize);
|
||||||
|
expect(first.hasNext).toBe(true);
|
||||||
|
expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId });
|
||||||
|
expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]);
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a fresh result for every search and preserves a successful empty result', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]);
|
||||||
|
|
||||||
|
const first = await service.search('user-1', { sector: 'finance' });
|
||||||
|
const second = await service.search('user-1', { sector: 'finance' });
|
||||||
|
|
||||||
|
expect(first).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||||
|
expect(second).toMatchObject({ hasNext: false, items: [], page: 1 });
|
||||||
|
expect(second.searchId).not.toBe(first.searchId);
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not store a result when the catalog source fails', async () => {
|
||||||
|
client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException());
|
||||||
|
|
||||||
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => {
|
||||||
|
const first = await service.search('user-1', {});
|
||||||
|
access.local.mockResolvedValueOnce({ revision: 'revision-2' });
|
||||||
|
|
||||||
|
await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 });
|
||||||
|
expect(client.getAllShares).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('null-normalizes optional source fields and rejects malformed required fields', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([
|
||||||
|
share(1, {
|
||||||
|
countryOfRisk: undefined,
|
||||||
|
currency: undefined,
|
||||||
|
isin: undefined,
|
||||||
|
realExchange: undefined,
|
||||||
|
sector: undefined,
|
||||||
|
shareType: undefined,
|
||||||
|
ticker: undefined,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const result = await service.search('user-1', {});
|
||||||
|
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
|
||||||
|
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||||
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves the catalog logo name in screener results', async () => {
|
||||||
|
client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]);
|
||||||
|
|
||||||
|
const result = await service.search('user-1', {});
|
||||||
|
|
||||||
|
expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Inject,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
ServiceUnavailableException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
||||||
|
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||||
|
import {
|
||||||
|
selectStockScreenerInstruments,
|
||||||
|
type StockScreenerConditions,
|
||||||
|
} from '../domain/stock-screener-selection.rules';
|
||||||
|
|
||||||
|
const MAX_SNAPSHOTS = 100;
|
||||||
|
const PAGE_SIZE = 30;
|
||||||
|
const MIN_PAGE = 1;
|
||||||
|
const MINIMUM_SNAPSHOT_COUNT = 1;
|
||||||
|
const SECONDS_PER_MINUTE = 60;
|
||||||
|
const SNAPSHOT_TTL_MINUTES = 5;
|
||||||
|
const MILLISECONDS_PER_SECOND = 1_000;
|
||||||
|
const SNAPSHOT_TTL_MS = SNAPSHOT_TTL_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
|
||||||
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
||||||
|
|
||||||
|
type StockScreenerItem = {
|
||||||
|
countryOfRisk: null | string;
|
||||||
|
currency: null | string;
|
||||||
|
instrumentUid: string;
|
||||||
|
isin: null | string;
|
||||||
|
logoName: null | string;
|
||||||
|
lot: null | number;
|
||||||
|
name: string;
|
||||||
|
realExchange: null | string;
|
||||||
|
sector: null | string;
|
||||||
|
shareType: null | string;
|
||||||
|
ticker: null | string;
|
||||||
|
};
|
||||||
|
type StockScreenerPage = {
|
||||||
|
asOf: string;
|
||||||
|
hasNext: boolean;
|
||||||
|
items: StockScreenerItem[];
|
||||||
|
page: number;
|
||||||
|
searchId: string;
|
||||||
|
};
|
||||||
|
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
||||||
|
type TbankAccess = {
|
||||||
|
local(userId: string): Promise<{ revision: string }>;
|
||||||
|
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StockScreenerService {
|
||||||
|
private readonly snapshots = new Map<string, Snapshot>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
|
||||||
|
private readonly client: TbankClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
|
||||||
|
const { instruments, revision } = await this.access.online(userId, async (connection) => ({
|
||||||
|
instruments: await this.client.getAllShares(connection.token),
|
||||||
|
revision: connection.revision,
|
||||||
|
}));
|
||||||
|
const asOf = new Date().toISOString();
|
||||||
|
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
|
||||||
|
.instruments.map((instrument) => this.item(instrument));
|
||||||
|
const searchId = randomUUID();
|
||||||
|
this.evict();
|
||||||
|
this.snapshots.set(searchId, {
|
||||||
|
asOf,
|
||||||
|
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
|
||||||
|
items: selected,
|
||||||
|
revision,
|
||||||
|
userId,
|
||||||
|
});
|
||||||
|
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
|
||||||
|
return this.result(searchId, this.snapshots.get(searchId) as Snapshot, MIN_PAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
async page(userId: string, searchId: string, page: number): Promise<StockScreenerPage> {
|
||||||
|
const snapshot = this.snapshots.get(searchId);
|
||||||
|
if (!snapshot || snapshot.expiresAt <= Date.now()) {
|
||||||
|
this.snapshots.delete(searchId);
|
||||||
|
this.logger.event('info', 'stock.screener.result.missing', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
if (snapshot.userId !== userId) {
|
||||||
|
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
const current = await this.access.local(userId);
|
||||||
|
if (snapshot.revision !== current.revision) {
|
||||||
|
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
|
||||||
|
throw new NotFoundException();
|
||||||
|
}
|
||||||
|
return this.result(searchId, snapshot, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
private result(searchId: string, snapshot: Snapshot, page: number): StockScreenerPage {
|
||||||
|
const start = (page - MIN_PAGE) * PAGE_SIZE;
|
||||||
|
if (!Number.isInteger(page) || page < MIN_PAGE || (start >= snapshot.items.length && !(page === MIN_PAGE && !snapshot.items.length))) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
const items = snapshot.items.slice(start, start + PAGE_SIZE);
|
||||||
|
return { asOf: snapshot.asOf, hasNext: start + PAGE_SIZE < snapshot.items.length, items, page, searchId };
|
||||||
|
}
|
||||||
|
|
||||||
|
private item(instrument: TbankShare): StockScreenerItem {
|
||||||
|
if (!instrument.uid || !UUID_PATTERN.test(instrument.uid) || !instrument.name?.trim()) {
|
||||||
|
throw new ServiceUnavailableException();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
countryOfRisk: instrument.countryOfRisk ?? null,
|
||||||
|
currency: instrument.currency ?? null,
|
||||||
|
instrumentUid: instrument.uid,
|
||||||
|
isin: instrument.isin ?? null,
|
||||||
|
lot: instrument.lot ?? null,
|
||||||
|
logoName: instrument.brand?.logoName?.trim() || null,
|
||||||
|
name: instrument.name,
|
||||||
|
realExchange: instrument.realExchange ?? null,
|
||||||
|
sector: instrument.sector ?? null,
|
||||||
|
shareType: instrument.shareType ?? null,
|
||||||
|
ticker: instrument.ticker ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private evict(): void {
|
||||||
|
for (const [searchId, snapshot] of this.snapshots) {
|
||||||
|
if (snapshot.expiresAt <= Date.now()) this.snapshots.delete(searchId);
|
||||||
|
}
|
||||||
|
while (this.snapshots.size >= MAX_SNAPSHOTS) {
|
||||||
|
const searchId = this.snapshots.keys().next().value;
|
||||||
|
if (searchId && this.snapshots.size >= MINIMUM_SNAPSHOT_COUNT) this.snapshots.delete(searchId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
||||||
|
|
||||||
|
const asOf = new Date('2026-10-10T12:00:00.000Z');
|
||||||
|
|
||||||
|
function share(overrides: Partial<TbankShare> = {}): TbankShare {
|
||||||
|
return {
|
||||||
|
apiTradeAvailableFlag: true,
|
||||||
|
buyAvailableFlag: true,
|
||||||
|
currency: 'RUB',
|
||||||
|
lot: 10,
|
||||||
|
name: 'North Star',
|
||||||
|
ticker: 'NSTR',
|
||||||
|
uid: 'uid-1',
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('selectStockScreenerInstruments', () => {
|
||||||
|
it('keeps only instruments matching every selected condition', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'A', sector: 'energy' }),
|
||||||
|
share({ currency: 'USD', isin: 'B', sector: 'energy', uid: 'uid-2' }),
|
||||||
|
share({ isin: 'C', name: 'Other', sector: 'energy', uid: 'uid-3' }),
|
||||||
|
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ currency: 'RUB', query: 'star', sector: 'energy' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||||
|
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||||
|
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
||||||
|
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||||
|
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||||
|
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||||
|
] as const)('excludes an unknown or unequal selected %s', (_condition, conditions, instrument) => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{ asOf, instruments: [instrument] },
|
||||||
|
conditions,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an inclusive maximum lot and ignores lots when it is not selected', () => {
|
||||||
|
const instruments = [
|
||||||
|
share({ isin: 'equal', lot: 10, uid: 'equal' }),
|
||||||
|
share({ isin: 'large', lot: 11, uid: 'large' }),
|
||||||
|
share({ isin: 'unknown', lot: undefined, uid: 'unknown' }),
|
||||||
|
share({ isin: 'zero', lot: 0, uid: 'zero' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(
|
||||||
|
selectStockScreenerInstruments({ asOf, instruments }, { maxLot: 10 }).instruments.map(
|
||||||
|
({ uid }) => uid,
|
||||||
|
),
|
||||||
|
).toEqual(['equal']);
|
||||||
|
expect(
|
||||||
|
selectStockScreenerInstruments({ asOf, instruments }, {}).instruments.map(({ uid }) => uid),
|
||||||
|
).toEqual(['equal', 'large', 'unknown', 'zero']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the smallest matching uid as the complete representative of an ISIN', () => {
|
||||||
|
const chosen = share({ isin: ' same ', lot: 1, name: 'Chosen', uid: 'a' });
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'SAME', lot: 99, name: 'Sibling', uid: 'z' }), chosen],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([chosen]);
|
||||||
|
expect(result.instruments[0]).toMatchObject({ lot: 1, name: 'Chosen', uid: 'a' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('chooses the sole matching mode of an issue', () => {
|
||||||
|
const matching = share({ isin: 'same', sector: 'energy', uid: 'matching' });
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'same', sector: 'finance', uid: 'smaller-uid' }), matching],
|
||||||
|
},
|
||||||
|
{ sector: 'energy' },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toEqual([matching]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps different ISIN issues from one issuer separate', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'common', shareType: 'common', uid: 'common' }),
|
||||||
|
share({ isin: 'preferred', shareType: 'preferred', uid: 'preferred' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['common', 'preferred']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats instruments without an ISIN as separate issues', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: ' ', uid: 'first' }), share({ isin: undefined, uid: 'second' })],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['first', 'second']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves exact whitespace-differing UIDs as separate blank-ISIN issues', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: ' ', uid: 'uid' }),
|
||||||
|
share({ isin: undefined, uid: ' uid ' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual([' uid ', 'uid']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the exact chosen uid for the later card link', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [share({ isin: 'same', uid: 'sibling' }), share({ isin: 'SAME', uid: 'chosen' })],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments[0]?.uid).toBe('chosen');
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[true, true, 1],
|
||||||
|
[true, false, 0],
|
||||||
|
[false, true, 0],
|
||||||
|
[false, false, 0],
|
||||||
|
[true, undefined, 0],
|
||||||
|
[undefined, true, 0],
|
||||||
|
] as const)(
|
||||||
|
'includes only confirmed buy and API availability: buy=%s api=%s',
|
||||||
|
(buyAvailableFlag, apiTradeAvailableFlag, expectedCount) => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{ asOf, instruments: [share({ apiTradeAvailableFlag, buyAvailableFlag })] },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments).toHaveLength(expectedCount);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('orders representatives by ticker and then uid before paging', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{
|
||||||
|
asOf,
|
||||||
|
instruments: [
|
||||||
|
share({ isin: 'c', ticker: 'B', uid: 'z' }),
|
||||||
|
share({ isin: 'a', ticker: 'A', uid: 'z' }),
|
||||||
|
share({ isin: 'b', ticker: 'A', uid: 'a' }),
|
||||||
|
share({ isin: 'empty', ticker: ' ', uid: 'empty' }),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.instruments.map(({ uid }) => uid)).toEqual(['empty', 'a', 'z', 'z']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
export type StockScreenerCatalog = {
|
||||||
|
asOf: Date;
|
||||||
|
instruments: TbankShare[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StockScreenerConditions = {
|
||||||
|
countryOfRisk?: string;
|
||||||
|
currency?: string;
|
||||||
|
maxLot?: number;
|
||||||
|
query?: string;
|
||||||
|
realExchange?: string;
|
||||||
|
sector?: string;
|
||||||
|
shareType?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IdentifiedShare = TbankShare & { uid: string };
|
||||||
|
const sortBefore = 0;
|
||||||
|
|
||||||
|
export function selectStockScreenerInstruments(
|
||||||
|
catalog: StockScreenerCatalog,
|
||||||
|
conditions: StockScreenerConditions,
|
||||||
|
): StockScreenerCatalog {
|
||||||
|
const representatives = new Map<string, IdentifiedShare>();
|
||||||
|
|
||||||
|
for (const instrument of catalog.instruments) {
|
||||||
|
if (!matches(instrument, conditions)) continue;
|
||||||
|
|
||||||
|
const { uid } = instrument;
|
||||||
|
const isin = instrument.isin?.trim().toUpperCase();
|
||||||
|
const issueKey = isin ? `isin:${isin}` : `uid:${uid}`;
|
||||||
|
const existing = representatives.get(issueKey);
|
||||||
|
if (!existing || uid.localeCompare(existing.uid) < sortBefore) {
|
||||||
|
representatives.set(issueKey, instrument);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
asOf: catalog.asOf,
|
||||||
|
instruments: [...representatives.values()].sort(
|
||||||
|
(left, right) =>
|
||||||
|
(left.ticker?.trim() ?? '').localeCompare(right.ticker?.trim() ?? '') ||
|
||||||
|
left.uid.localeCompare(right.uid),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(
|
||||||
|
instrument: TbankShare,
|
||||||
|
conditions: StockScreenerConditions,
|
||||||
|
): instrument is IdentifiedShare {
|
||||||
|
if (
|
||||||
|
!instrument.uid?.trim() ||
|
||||||
|
instrument.buyAvailableFlag !== true ||
|
||||||
|
instrument.apiTradeAvailableFlag !== true
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
conditions.query !== undefined &&
|
||||||
|
!`${instrument.name ?? ''}\n${instrument.ticker ?? ''}`
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(conditions.query.toLowerCase())
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (conditions.sector !== undefined && instrument.sector !== conditions.sector) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.shareType !== undefined &&
|
||||||
|
instrument.shareType !== conditions.shareType
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.currency !== undefined &&
|
||||||
|
instrument.currency !== conditions.currency
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.realExchange !== undefined &&
|
||||||
|
instrument.realExchange !== conditions.realExchange
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
conditions.countryOfRisk !== undefined &&
|
||||||
|
instrument.countryOfRisk !== conditions.countryOfRisk
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minimumPositiveLot = 0;
|
||||||
|
return (
|
||||||
|
conditions.maxLot === undefined ||
|
||||||
|
(Number.isInteger(instrument.lot) &&
|
||||||
|
instrument.lot !== undefined &&
|
||||||
|
instrument.lot > minimumPositiveLot &&
|
||||||
|
instrument.lot <= conditions.maxLot)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,21 @@
|
|||||||
import type { Request } from 'express';
|
import type { Request } from 'express';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
|
HttpCode,
|
||||||
|
HttpStatus,
|
||||||
Param,
|
Param,
|
||||||
ParseUUIDPipe,
|
ParseUUIDPipe,
|
||||||
|
Post,
|
||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
ValidationPipe,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||||
@@ -18,6 +24,7 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
|
|||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
import { StockIncomeService } from '../application/stock-income.service';
|
import { StockIncomeService } from '../application/stock-income.service';
|
||||||
import { StockMarketService } from '../application/stock-market.service';
|
import { StockMarketService } from '../application/stock-market.service';
|
||||||
|
import { StockScreenerService } from '../application/stock-screener.service';
|
||||||
import {
|
import {
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
StockCandlesResourceDto,
|
StockCandlesResourceDto,
|
||||||
@@ -26,6 +33,9 @@ import {
|
|||||||
StockPositionResourceDto,
|
StockPositionResourceDto,
|
||||||
StockQuoteResourceDto,
|
StockQuoteResourceDto,
|
||||||
StockResourceDto,
|
StockResourceDto,
|
||||||
|
StockScreenerCriteriaDto,
|
||||||
|
StockScreenerPageDto,
|
||||||
|
StockScreenerPageQueryDto,
|
||||||
StockSearchQueryDto,
|
StockSearchQueryDto,
|
||||||
StockSearchResourceDto,
|
StockSearchResourceDto,
|
||||||
StockTickerParamsDto,
|
StockTickerParamsDto,
|
||||||
@@ -42,6 +52,7 @@ export class StockController {
|
|||||||
private readonly market: StockMarketService,
|
private readonly market: StockMarketService,
|
||||||
private readonly income: StockIncomeService,
|
private readonly income: StockIncomeService,
|
||||||
private readonly companyInfo: GetStockCompanyUseCase,
|
private readonly companyInfo: GetStockCompanyUseCase,
|
||||||
|
private readonly screener: StockScreenerService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -71,6 +82,33 @@ export class StockController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('screener/search')
|
||||||
|
@HttpCode(HttpStatus.OK)
|
||||||
|
@ApiOperation({ operationId: 'createStockScreenerSearch' })
|
||||||
|
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||||
|
searchScreener(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
||||||
|
criteria: StockScreenerCriteriaDto,
|
||||||
|
) {
|
||||||
|
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||||
|
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||||
|
throw new BadRequestException();
|
||||||
|
}
|
||||||
|
return this.screener.search(request.user.sub, criteria);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('screener/search/:searchId')
|
||||||
|
@ApiOperation({ operationId: 'readStockScreenerPage' })
|
||||||
|
@ApiOkResponse({ type: StockScreenerPageDto })
|
||||||
|
pageScreener(
|
||||||
|
@Req() request: Request & { user: AccessPayload },
|
||||||
|
@Param('searchId') searchId: string,
|
||||||
|
@Query() query: StockScreenerPageQueryDto,
|
||||||
|
) {
|
||||||
|
return this.screener.page(request.user.sub, searchId, query.page);
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':uid')
|
@Get(':uid')
|
||||||
@ApiOkResponse({ type: StockResourceDto })
|
@ApiOkResponse({ type: StockResourceDto })
|
||||||
get(
|
get(
|
||||||
@@ -111,13 +149,8 @@ export class StockController {
|
|||||||
position(
|
position(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.readPosition(
|
return this.income.readPosition(request.user.sub, uid);
|
||||||
request.user.sub,
|
|
||||||
uid,
|
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/company')
|
@Get(':uid/company')
|
||||||
|
|||||||
@@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
|||||||
import { Transform } from 'class-transformer';
|
import { Transform } from 'class-transformer';
|
||||||
import {
|
import {
|
||||||
IsIn,
|
IsIn,
|
||||||
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
MaxLength,
|
MaxLength,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
@@ -21,6 +23,86 @@ import {
|
|||||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||||
|
|
||||||
const MIN_TICKER_LENGTH = 1;
|
const MIN_TICKER_LENGTH = 1;
|
||||||
|
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||||
|
const MIN_PAGE = 1;
|
||||||
|
|
||||||
|
export class StockScreenerCriteriaDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: MAX_SCREENER_QUERY_LENGTH })
|
||||||
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
|
value === '' ? undefined : value,
|
||||||
|
)
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
@MaxLength(MAX_SCREENER_QUERY_LENGTH)
|
||||||
|
query?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
sector?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
shareType?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
currency?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
realExchange?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
|
countryOfRisk?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ minimum: MIN_TICKER_LENGTH, type: 'integer' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_TICKER_LENGTH)
|
||||||
|
maxLot?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerPageQueryDto {
|
||||||
|
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' })
|
||||||
|
@Transform(({ value }: { value: unknown }) => Number(value))
|
||||||
|
@IsInt()
|
||||||
|
@Min(MIN_PAGE)
|
||||||
|
declare page: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerItemDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
|
||||||
|
@ApiProperty() declare name: string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare ticker: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare isin: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare logoName: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare shareType: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare sector: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
|
||||||
|
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
|
||||||
|
@ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StockScreenerPageDto {
|
||||||
|
@ApiProperty() declare searchId: string;
|
||||||
|
@ApiProperty({ format: 'date-time' }) declare asOf: string;
|
||||||
|
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
|
||||||
|
@ApiProperty() declare hasNext: boolean;
|
||||||
|
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
||||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
import { IsIn, IsOptional } from 'class-validator';
|
||||||
|
|
||||||
export const availabilityValues = ['missing', 'partial', 'complete'] as const;
|
export const availabilityValues = [
|
||||||
|
'available',
|
||||||
|
'initial-loading',
|
||||||
|
'temporarily-unavailable',
|
||||||
|
'connection-unavailable',
|
||||||
|
] as const;
|
||||||
|
export const completenessValues = ['complete', 'incomplete'] as const;
|
||||||
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
|
||||||
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const;
|
export const attemptStateValues = [
|
||||||
|
'none',
|
||||||
|
'running',
|
||||||
|
'succeeded',
|
||||||
|
'partial',
|
||||||
|
'failed',
|
||||||
|
] as const;
|
||||||
|
|
||||||
export class InvestmentReadQueryDto {
|
export class InvestmentReadQueryDto {
|
||||||
@ApiPropertyOptional({ enum: ['observe'] })
|
@ApiPropertyOptional({ enum: ['observe'] })
|
||||||
@@ -12,17 +24,31 @@ export class InvestmentReadQueryDto {
|
|||||||
declare sync?: 'observe';
|
declare sync?: 'observe';
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvestmentSyncDto {
|
export class InvestmentAttemptProblemDto {
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||||
declare id: null | string;
|
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||||
|
|
||||||
@ApiProperty({ enum: syncStateValues })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare state: (typeof syncStateValues)[number];
|
declare accountId: null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: String, nullable: true })
|
@ApiProperty({ type: String, nullable: true })
|
||||||
declare errorCode: null | string;
|
declare errorCode: null | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class InvestmentAttemptDto {
|
||||||
|
@ApiProperty({ enum: attemptStateValues })
|
||||||
|
declare state: (typeof attemptStateValues)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
declare startedAt: null | string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
declare finishedAt: null | string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true })
|
||||||
|
declare problems: InvestmentAttemptProblemDto[];
|
||||||
|
}
|
||||||
|
|
||||||
export class InvestmentRangeDto {
|
export class InvestmentRangeDto {
|
||||||
@ApiPropertyOptional()
|
@ApiPropertyOptional()
|
||||||
accountId?: string;
|
accountId?: string;
|
||||||
@@ -61,9 +87,6 @@ export class InvestmentDependencyDto {
|
|||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
declare dataRevision: string;
|
declare dataRevision: string;
|
||||||
|
|
||||||
@ApiProperty()
|
|
||||||
declare stateVersion: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: String })
|
@ApiPropertyOptional({ type: String })
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
|
|
||||||
@@ -76,14 +99,21 @@ export class InvestmentDependencyDto {
|
|||||||
@ApiProperty({ enum: availabilityValues })
|
@ApiProperty({ enum: availabilityValues })
|
||||||
declare availability: (typeof availabilityValues)[number];
|
declare availability: (typeof availabilityValues)[number];
|
||||||
|
|
||||||
|
@ApiProperty({ enum: completenessValues })
|
||||||
|
declare completeness: (typeof completenessValues)[number];
|
||||||
|
|
||||||
@ApiProperty({ enum: freshnessValues })
|
@ApiProperty({ enum: freshnessValues })
|
||||||
declare freshness: (typeof freshnessValues)[number];
|
declare freshness: (typeof freshnessValues)[number];
|
||||||
|
|
||||||
@ApiProperty({ type: String, format: 'date-time', nullable: true })
|
@ApiProperty({ type: String, format: 'date-time', nullable: true })
|
||||||
declare lastSuccessfulAt: null | string;
|
declare lastSuccessfulAt: null | string;
|
||||||
|
|
||||||
@ApiProperty({ type: InvestmentSyncDto })
|
@ApiProperty({ type: InvestmentAttemptDto })
|
||||||
declare sync: InvestmentSyncDto;
|
declare lastAttempt: InvestmentAttemptDto;
|
||||||
|
|
||||||
|
// Internal resource-source work remains available to existing source owners.
|
||||||
|
// It is deliberately undecorated and therefore not part of the HTTP schema.
|
||||||
|
declare sync: { errorCode: null | string; id: null | string; state: 'failed' | 'idle' | 'running' };
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
|
export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
|
jest.mock('../../prisma/prisma.service', () => ({
|
||||||
|
PrismaService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
import { investmentResourceMeta } from './investment-resource.mapper';
|
import { investmentResourceMeta } from './investment-resource.mapper';
|
||||||
|
import { hourlyAttempt } from './tbank-sync/tbank-sync.repository';
|
||||||
|
|
||||||
describe('investment resource source freshness', () => {
|
describe('investment resource source freshness', () => {
|
||||||
afterEach(() => jest.useRealTimers());
|
afterEach(() => jest.useRealTimers());
|
||||||
@@ -29,7 +34,6 @@ describe('investment resource source freshness', () => {
|
|||||||
const stale = investmentResourceMeta(input);
|
const stale = investmentResourceMeta(input);
|
||||||
expect(stale.freshness).toBe('stale');
|
expect(stale.freshness).toBe('stale');
|
||||||
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
|
||||||
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
@@ -37,6 +41,105 @@ describe('investment resource source freshness', () => {
|
|||||||
}).freshness,
|
}).freshness,
|
||||||
).toBe('unknown');
|
).toBe('unknown');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('keeps confirmed data state independent from a failed hourly attempt', () => {
|
||||||
|
jest.useFakeTimers().setSystemTime(new Date('2026-09-25T11:30:00Z'));
|
||||||
|
const meta = investmentResourceMeta({
|
||||||
|
resourceKey: 'portfolio/a',
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date('2026-09-25T11:00:00Z'),
|
||||||
|
freshForMs: 3_600_000,
|
||||||
|
work: null,
|
||||||
|
lastAttempt: {
|
||||||
|
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(meta).toMatchObject({
|
||||||
|
availability: 'available',
|
||||||
|
completeness: 'complete',
|
||||||
|
freshness: 'fresh',
|
||||||
|
lastSuccessfulAt: '2026-09-25T11:00:00.000Z',
|
||||||
|
lastAttempt: {
|
||||||
|
state: 'partial',
|
||||||
|
startedAt: '2026-09-25T12:00:00.000Z',
|
||||||
|
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects only the matching hourly section into a resource attempt', () => {
|
||||||
|
const attempt = hourlyAttempt(
|
||||||
|
{
|
||||||
|
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' },
|
||||||
|
{ section: 'operations', accountId: 'a', errorCode: null },
|
||||||
|
{ section: 'events', errorCode: 'CALENDAR_UNAVAILABLE' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'portfolio',
|
||||||
|
'a',
|
||||||
|
);
|
||||||
|
|
||||||
|
const meta = investmentResourceMeta({
|
||||||
|
resourceKey: 'portfolio/a',
|
||||||
|
dataRevision: '1',
|
||||||
|
availability: 'complete',
|
||||||
|
fetchedAt: new Date('2026-09-25T11:00:00Z'),
|
||||||
|
freshForMs: 3_600_000,
|
||||||
|
work: { id: 'legacy-work', state: 'failed', errorCode: 'LEGACY_FAILURE' },
|
||||||
|
lastAttempt: attempt,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(meta).toMatchObject({
|
||||||
|
availability: 'available',
|
||||||
|
completeness: 'complete',
|
||||||
|
lastAttempt: {
|
||||||
|
state: 'partial',
|
||||||
|
startedAt: '2026-09-25T12:00:00.000Z',
|
||||||
|
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||||
|
problems: [{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
expect(JSON.stringify(meta)).not.toContain('legacy-work');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('projects no confirmed result as incomplete without inventing a successful attempt', () => {
|
||||||
|
expect(
|
||||||
|
investmentResourceMeta({
|
||||||
|
resourceKey: 'accounts',
|
||||||
|
dataRevision: '0',
|
||||||
|
availability: 'missing',
|
||||||
|
fetchedAt: null,
|
||||||
|
freshForMs: 3_600_000,
|
||||||
|
work: { id: 'work', state: 'running', errorCode: null },
|
||||||
|
}),
|
||||||
|
).toMatchObject({
|
||||||
|
availability: 'initial-loading',
|
||||||
|
completeness: 'incomplete',
|
||||||
|
freshness: 'unknown',
|
||||||
|
lastSuccessfulAt: null,
|
||||||
|
lastAttempt: {
|
||||||
|
state: 'running',
|
||||||
|
startedAt: null,
|
||||||
|
finishedAt: null,
|
||||||
|
problems: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('dependent resource lifecycle', () => {
|
describe('dependent resource lifecycle', () => {
|
||||||
@@ -61,15 +164,14 @@ describe('dependent resource lifecycle', () => {
|
|||||||
...input,
|
...input,
|
||||||
dependencies: [failed, running],
|
dependencies: [failed, running],
|
||||||
});
|
});
|
||||||
expect(parent.sync).toEqual(running.sync);
|
expect(parent.lastAttempt.state).toBe('none');
|
||||||
expect(parent.availability).toBe('complete');
|
expect(parent.availability).toBe('available');
|
||||||
const terminal = investmentResourceMeta({
|
const terminal = investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
dependencies: [failed],
|
dependencies: [failed],
|
||||||
});
|
});
|
||||||
expect(terminal.sync).toEqual(failed.sync);
|
expect(terminal.sync).toEqual(failed.sync);
|
||||||
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
|
||||||
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
|
|
||||||
expect(
|
expect(
|
||||||
investmentResourceMeta({
|
investmentResourceMeta({
|
||||||
...input,
|
...input,
|
||||||
|
|||||||
@@ -1,44 +1,34 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
import { InvestmentResourceMetaDto } from './investment-resource.dto';
|
||||||
|
|
||||||
|
type LegacyAvailability = 'available' | 'complete' | 'connection-unavailable' | 'initial-loading' | 'missing' | 'partial' | 'temporarily-unavailable';
|
||||||
|
type Work = null | { errorCode: null | string; id: string; state: string };
|
||||||
|
type Attempt = NonNullable<InvestmentResourceMetaDto['lastAttempt']>;
|
||||||
|
|
||||||
export function investmentResourceMeta(input: {
|
export function investmentResourceMeta(input: {
|
||||||
availability: InvestmentResourceMetaDto['availability'];
|
availability: LegacyAvailability;
|
||||||
coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>;
|
coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>;
|
||||||
dataRevision: string;
|
dataRevision: string;
|
||||||
dependencies?: InvestmentResourceMetaDto[];
|
dependencies?: InvestmentResourceMetaDto[];
|
||||||
fetchedAt: Date | null;
|
fetchedAt: Date | null;
|
||||||
freshForMs: number;
|
freshForMs: number;
|
||||||
|
lastAttempt?: Attempt;
|
||||||
resourceKey: string;
|
resourceKey: string;
|
||||||
unavailableReason?: string;
|
unavailableReason?: string;
|
||||||
work: null | { errorCode: null | string; id: string; state: string };
|
work: Work;
|
||||||
}): InvestmentResourceMetaDto {
|
}): InvestmentResourceMetaDto {
|
||||||
const ownSync = {
|
const ownSync = sourceSync(input.work);
|
||||||
id: input.work?.id ?? null,
|
const syncStates = [
|
||||||
state:
|
|
||||||
input.work?.state === 'running'
|
|
||||||
? ('running' as const)
|
|
||||||
: input.work?.state === 'failed'
|
|
||||||
? ('failed' as const)
|
|
||||||
: ('idle' as const),
|
|
||||||
errorCode: input.work?.errorCode ?? null,
|
|
||||||
};
|
|
||||||
const states = [
|
|
||||||
ownSync,
|
ownSync,
|
||||||
...(input.dependencies ?? [])
|
...(input.dependencies ?? [])
|
||||||
.filter((dependency) => !dependency.unavailableReason)
|
.filter((dependency) => !dependency.unavailableReason)
|
||||||
.map((dependency) => dependency.sync),
|
.map((dependency) => dependency.sync)
|
||||||
|
.filter((state): state is typeof ownSync => Boolean(state)),
|
||||||
];
|
];
|
||||||
const sync =
|
const sourceWork =
|
||||||
states.find((state) => state.state === 'running') ??
|
syncStates.find((state) => state.state === 'running') ??
|
||||||
states.find((state) => state.state === 'queued') ??
|
syncStates.find((state) => state.state === 'failed') ??
|
||||||
states.find((state) => state.state === 'failed') ??
|
|
||||||
ownSync;
|
ownSync;
|
||||||
const sourceFreshness = input.fetchedAt
|
const sourceFreshness = freshnessOf(input.fetchedAt, input.freshForMs);
|
||||||
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
|
|
||||||
? ('fresh' as const)
|
|
||||||
: ('stale' as const)
|
|
||||||
: ('unknown' as const);
|
|
||||||
const freshness = [
|
const freshness = [
|
||||||
sourceFreshness,
|
sourceFreshness,
|
||||||
...(input.dependencies ?? [])
|
...(input.dependencies ?? [])
|
||||||
@@ -60,30 +50,75 @@ export function investmentResourceMeta(input: {
|
|||||||
? { unavailableReason: input.unavailableReason }
|
? { unavailableReason: input.unavailableReason }
|
||||||
: {}),
|
: {}),
|
||||||
dataRevision: input.dataRevision,
|
dataRevision: input.dataRevision,
|
||||||
availability: input.availability,
|
availability: availability(input.availability, input.work),
|
||||||
freshness: freshness.includes('unknown')
|
completeness:
|
||||||
? ('unknown' as const)
|
availability(input.availability, input.work) === 'available'
|
||||||
: freshness.includes('stale')
|
? ('complete' as const)
|
||||||
? ('stale' as const)
|
: ('incomplete' as const),
|
||||||
: ('fresh' as const),
|
freshness: combinedFreshness(freshness),
|
||||||
lastSuccessfulAt: timestamps[0] ?? null,
|
lastSuccessfulAt: timestamps[0] ?? null,
|
||||||
sync,
|
lastAttempt: input.lastAttempt ??
|
||||||
|
attempt(input.work),
|
||||||
...(input.coverage ? { coverage: input.coverage } : {}),
|
...(input.coverage ? { coverage: input.coverage } : {}),
|
||||||
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
...(input.dependencies ? { dependencies: input.dependencies } : {}),
|
||||||
};
|
};
|
||||||
return {
|
return Object.defineProperty(meta, 'sync', {
|
||||||
...meta,
|
enumerable: false,
|
||||||
stateVersion: createHash('sha256')
|
value: sourceWork,
|
||||||
.update(
|
}) as InvestmentResourceMetaDto;
|
||||||
JSON.stringify({
|
}
|
||||||
...meta,
|
|
||||||
// The requested window is identified by resourceKey. Its moving "now"
|
function sourceSync(work: Work) {
|
||||||
// boundary is not a change to saved coverage between status and GET.
|
let state: 'failed' | 'idle' | 'running' = 'idle';
|
||||||
coverage: meta.coverage
|
if (work?.state === 'running') state = 'running';
|
||||||
? { ...meta.coverage, requested: undefined }
|
else if (work?.state === 'failed') state = 'failed';
|
||||||
: undefined,
|
return { id: work?.id ?? null, state, errorCode: work?.errorCode ?? null };
|
||||||
}),
|
}
|
||||||
)
|
|
||||||
.digest('base64url'),
|
function freshnessOf(fetchedAt: Date | null, freshForMs: number) {
|
||||||
};
|
if (!fetchedAt) return 'unknown' as const;
|
||||||
|
return Date.now() - fetchedAt.valueOf() < freshForMs
|
||||||
|
? ('fresh' as const)
|
||||||
|
: ('stale' as const);
|
||||||
|
}
|
||||||
|
|
||||||
|
function combinedFreshness(values: InvestmentResourceMetaDto['freshness'][]) {
|
||||||
|
if (values.includes('unknown')) return 'unknown' as const;
|
||||||
|
if (values.includes('stale')) return 'stale' as const;
|
||||||
|
return 'fresh' as const;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attempt(work: Work): Attempt {
|
||||||
|
const base = { startedAt: null, finishedAt: null };
|
||||||
|
if (work?.state === 'running') return { ...base, state: 'running', problems: [] };
|
||||||
|
if (work?.state === 'failed') {
|
||||||
|
return {
|
||||||
|
...base,
|
||||||
|
state: 'failed',
|
||||||
|
problems: work.errorCode
|
||||||
|
? [{ section: 'membership', accountId: null, errorCode: work.errorCode }]
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ...base, state: 'none', problems: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
function availability(value: LegacyAvailability, work: Work) {
|
||||||
|
if (
|
||||||
|
value === 'available' ||
|
||||||
|
value === 'connection-unavailable' ||
|
||||||
|
value === 'initial-loading' ||
|
||||||
|
value === 'temporarily-unavailable'
|
||||||
|
) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
if (value === 'complete') return 'available' as const;
|
||||||
|
if (value === 'partial') {
|
||||||
|
return work?.state === 'running'
|
||||||
|
? ('initial-loading' as const)
|
||||||
|
: ('temporarily-unavailable' as const);
|
||||||
|
}
|
||||||
|
return work?.state === 'running'
|
||||||
|
? ('initial-loading' as const)
|
||||||
|
: ('temporarily-unavailable' as const);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ describe('screen dependency and history planning', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => {
|
it('prioritizes discovery, the selected period, then history; Moscow dates are converted once', () => {
|
||||||
const plan = planInvestmentScreen(
|
const plan = planInvestmentScreen(
|
||||||
{
|
{
|
||||||
screen: 'income',
|
screen: 'income',
|
||||||
@@ -32,6 +32,17 @@ describe('screen dependency and history planning', () => {
|
|||||||
const selected = plan.sources.filter(
|
const selected = plan.sources.filter(
|
||||||
(source) => source.kind === 'operations' && source.priority === 'screen',
|
(source) => source.kind === 'operations' && source.priority === 'screen',
|
||||||
);
|
);
|
||||||
|
// The single selected range is chunked into month-aligned intervals.
|
||||||
|
expect(selected).toHaveLength(2);
|
||||||
|
expect(
|
||||||
|
selected.every(
|
||||||
|
(source) =>
|
||||||
|
source.from != null &&
|
||||||
|
source.from.toISOString() >= '2024-01-31T21:00:00.000Z' &&
|
||||||
|
source.to != null &&
|
||||||
|
source.to.toISOString() <= '2024-02-29T21:00:00.000Z',
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
expect(
|
expect(
|
||||||
selected.some(
|
selected.some(
|
||||||
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
|
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
|
||||||
@@ -42,9 +53,7 @@ describe('screen dependency and history planning', () => {
|
|||||||
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
|
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
|
||||||
),
|
),
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(plan.unavailable).toEqual([
|
expect(plan.unavailable).toEqual([]);
|
||||||
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
|
|
||||||
]);
|
|
||||||
const firstHistory = plan.sources.findIndex(
|
const firstHistory = plan.sources.findIndex(
|
||||||
(source) => source.priority === 'history',
|
(source) => source.priority === 'history',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -111,13 +111,6 @@ export function planInvestmentScreen(
|
|||||||
priority: 'screen' as const,
|
priority: 'screen' as const,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
const knownOpening =
|
|
||||||
accounts.length && accounts.every((account) => account.openedOn)
|
|
||||||
? new Date(
|
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
|
||||||
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
|
|
||||||
)
|
|
||||||
: undefined;
|
|
||||||
if (!context.membershipKnown) return { sources, unavailable, now };
|
if (!context.membershipKnown) return { sources, unavailable, now };
|
||||||
if (
|
if (
|
||||||
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
||||||
@@ -142,23 +135,9 @@ export function planInvestmentScreen(
|
|||||||
const period = planIncomePeriod(
|
const period = planIncomePeriod(
|
||||||
screen.screen === 'income' ? screen.from : undefined,
|
screen.screen === 'income' ? screen.from : undefined,
|
||||||
screen.screen === 'income' ? screen.to : undefined,
|
screen.screen === 'income' ? screen.to : undefined,
|
||||||
knownOpening,
|
|
||||||
);
|
);
|
||||||
for (const range of [
|
const bounds = dateOnlyBounds(period.selected, now);
|
||||||
period.selected,
|
operations(bounds.from, bounds.to);
|
||||||
...(screen.screen === 'income'
|
|
||||||
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
|
|
||||||
: []),
|
|
||||||
]) {
|
|
||||||
const bounds = dateOnlyBounds(range, now);
|
|
||||||
operations(bounds.from, bounds.to);
|
|
||||||
}
|
|
||||||
if (screen.screen === 'income' && !period.yearAgo) {
|
|
||||||
unavailable.push({
|
|
||||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
|
||||||
reason: 'calendar_or_account_boundary',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (screen.screen === 'account') {
|
if (screen.screen === 'account') {
|
||||||
const range = ledgerRange(
|
const range = ledgerRange(
|
||||||
screen.from,
|
screen.from,
|
||||||
@@ -267,25 +246,6 @@ export function planInvestmentScreen(
|
|||||||
priority: 'screen',
|
priority: 'screen',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (
|
|
||||||
!force &&
|
|
||||||
['account', 'events', 'income', 'operations'].includes(screen.screen)
|
|
||||||
) {
|
|
||||||
if (instrument.historyFrom) {
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: 'candles',
|
|
||||||
from: instrument.historyFrom,
|
|
||||||
to: now,
|
|
||||||
priority: 'history',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unavailable.push({
|
|
||||||
resourceKey: `candles/${instrument.uid}/all`,
|
|
||||||
reason: 'history_start_unavailable',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
||||||
sources.push(
|
sources.push(
|
||||||
|
|||||||
@@ -209,6 +209,12 @@ export class BrokerageOperationsSyncService {
|
|||||||
'tbank.operations.sync.persistence_failed',
|
'tbank.operations.sync.persistence_failed',
|
||||||
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
|
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
|
||||||
);
|
);
|
||||||
|
// The step could not even record its failure; close the attempt
|
||||||
|
// durably so it does not stay running. Best effort — an open row
|
||||||
|
// is re-reserved by the next request.
|
||||||
|
await this.repository
|
||||||
|
.fail(attempt, false)
|
||||||
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
const next = pending ? await this.repository.pending(attempt) : null;
|
const next = pending ? await this.repository.pending(attempt) : null;
|
||||||
if (next) {
|
if (next) {
|
||||||
|
|||||||
@@ -17,8 +17,13 @@ import {
|
|||||||
ledgerRange,
|
ledgerRange,
|
||||||
planInvestmentScreen,
|
planInvestmentScreen,
|
||||||
} from '../../investment-sync-plan';
|
} from '../../investment-sync-plan';
|
||||||
|
import {
|
||||||
|
hourlyAttempt,
|
||||||
|
TbankSyncRepository,
|
||||||
|
} from '../../tbank-sync/tbank-sync.repository';
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
|
confirmedOperationRanges,
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
@@ -58,6 +63,7 @@ export class BrokerageOperationsService {
|
|||||||
private readonly sync: BrokerageOperationsSyncService,
|
private readonly sync: BrokerageOperationsSyncService,
|
||||||
private readonly membership: TbankMembershipService,
|
private readonly membership: TbankMembershipService,
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly hourlySync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(
|
async read(
|
||||||
@@ -134,52 +140,17 @@ export class BrokerageOperationsService {
|
|||||||
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
if (query.sync !== 'observe') {
|
// The ledger only starts the first-time load of missing intervals; the
|
||||||
const discovery = await this.membership.request(
|
// mutable tail belongs to the hourly walk and membership is not rechecked.
|
||||||
userId,
|
if (intervals.length) {
|
||||||
connection.revision,
|
await this.sync.readPlan(userId, intervals, {
|
||||||
);
|
movingEnd,
|
||||||
if (discovery) {
|
history: true,
|
||||||
void discovery.done
|
revision: connection.revision,
|
||||||
.then(async () => {
|
});
|
||||||
const current = await this.access.local(userId, accountId);
|
|
||||||
if (current.revision !== connection.revision) return;
|
|
||||||
const currentRange = ledgerRange(
|
|
||||||
query.from,
|
|
||||||
query.to,
|
|
||||||
query.instrumentUid,
|
|
||||||
current.accounts.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
const targets = current.accounts
|
|
||||||
.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
)
|
|
||||||
.flatMap((account) =>
|
|
||||||
currentRange.from < currentRange.to
|
|
||||||
? [{ accountId: account.accountId, ...currentRange }]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
await this.sync.readPlan(userId, targets, {
|
|
||||||
movingEnd,
|
|
||||||
history: true,
|
|
||||||
revision: connection.revision,
|
|
||||||
});
|
|
||||||
})
|
|
||||||
.catch(() => undefined);
|
|
||||||
} else if (intervals.length) {
|
|
||||||
await this.sync.readPlan(userId, intervals, {
|
|
||||||
movingEnd,
|
|
||||||
history: true,
|
|
||||||
revision: connection.revision,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const saved = await this.repository.readPage(readInput);
|
const saved = await this.repository.readPage(readInput);
|
||||||
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const accounts = saved?.connection.accounts ?? [];
|
const accounts = saved?.connection.accounts ?? [];
|
||||||
const targets = accounts.flatMap((account) =>
|
const targets = accounts.flatMap((account) =>
|
||||||
from < to
|
from < to
|
||||||
@@ -196,22 +167,7 @@ export class BrokerageOperationsService {
|
|||||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||||
movingEnd,
|
movingEnd,
|
||||||
});
|
});
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
||||||
(saved?.coverage ?? [])
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
(!row.instrumentUid ||
|
|
||||||
row.instrumentUid === target.instrumentUid) &&
|
|
||||||
row.from < target.to &&
|
|
||||||
target.from < row.to,
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const missing = uncoveredOperationIntervals(
|
const missing = uncoveredOperationIntervals(
|
||||||
plan.intervals,
|
plan.intervals,
|
||||||
confirmed.map((range) => ({
|
confirmed.map((range) => ({
|
||||||
@@ -266,26 +222,10 @@ export class BrokerageOperationsService {
|
|||||||
dataRevision: value.dataRevision,
|
dataRevision: value.dataRevision,
|
||||||
availability: 'complete',
|
availability: 'complete',
|
||||||
fetchedAt: value.fetchedAt,
|
fetchedAt: value.fetchedAt,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: tbankSourceTtlMs.portfolio,
|
||||||
work: null,
|
work: null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
if (query.sync !== 'observe') {
|
|
||||||
const identities = new Map(
|
|
||||||
rows.map((row) => {
|
|
||||||
const identity = row.payload as TbankOperationItem;
|
|
||||||
return [
|
|
||||||
metadataKey(identity),
|
|
||||||
{ instrumentUid: identity.instrumentUid, figi: identity.figi },
|
|
||||||
] as const;
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const identity of identities.values()) {
|
|
||||||
if (!instruments.get(metadataKey(identity))?.fresh) {
|
|
||||||
await this.metadata.request(userId, connection.revision, identity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const membershipMeta = investmentResourceMeta({
|
const membershipMeta = investmentResourceMeta({
|
||||||
resourceKey: 'accounts/membership',
|
resourceKey: 'accounts/membership',
|
||||||
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
||||||
@@ -309,6 +249,7 @@ export class BrokerageOperationsService {
|
|||||||
? STALE_FRESHNESS_MS
|
? STALE_FRESHNESS_MS
|
||||||
: Number.POSITIVE_INFINITY,
|
: Number.POSITIVE_INFINITY,
|
||||||
work: work ?? saved?.membershipWork ?? null,
|
work: work ?? saved?.membershipWork ?? null,
|
||||||
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membershipMeta, ...metadataDependencies],
|
dependencies: [membershipMeta, ...metadataDependencies],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serialize),
|
||||||
@@ -360,7 +301,7 @@ export class BrokerageOperationsService {
|
|||||||
const hasNext = Boolean(saved && saved.rows.length > limit);
|
const hasNext = Boolean(saved && saved.rows.length > limit);
|
||||||
return {
|
return {
|
||||||
data:
|
data:
|
||||||
meta.availability === 'missing'
|
meta.completeness === 'incomplete'
|
||||||
? null
|
? null
|
||||||
: {
|
: {
|
||||||
accountId,
|
accountId,
|
||||||
@@ -494,6 +435,7 @@ export class BrokerageOperationsService {
|
|||||||
saved = await this.repository.readPage(input);
|
saved = await this.repository.readPage(input);
|
||||||
}
|
}
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const membership = investmentResourceMeta({
|
const membership = investmentResourceMeta({
|
||||||
resourceKey: 'accounts/membership',
|
resourceKey: 'accounts/membership',
|
||||||
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
|
||||||
@@ -510,7 +452,11 @@ export class BrokerageOperationsService {
|
|||||||
key: `${period.from}/${period.to}`,
|
key: `${period.from}/${period.to}`,
|
||||||
movingEnd: false,
|
movingEnd: false,
|
||||||
}));
|
}));
|
||||||
return periods.map((period, index) => {
|
const rows = saved?.rows ?? [];
|
||||||
|
const instruments = await this.metadata.savedMany(
|
||||||
|
rows.map((row) => row.payload as TbankOperationItem),
|
||||||
|
);
|
||||||
|
return periods.map((period) => {
|
||||||
const targets = (saved?.connection.accounts ?? []).map((account) => ({
|
const targets = (saved?.connection.accounts ?? []).map((account) => ({
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
...period,
|
...period,
|
||||||
@@ -544,22 +490,9 @@ export class BrokerageOperationsService {
|
|||||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
||||||
(complete ? saved?.connection.membershipAt : null) ??
|
(complete ? saved?.connection.membershipAt : null) ??
|
||||||
null;
|
null;
|
||||||
const confirmed = plan.intervals.flatMap((target) =>
|
const confirmed = confirmedOperationRanges(plan.intervals, sources);
|
||||||
sources
|
|
||||||
.filter(
|
|
||||||
(row) =>
|
|
||||||
row.accountId === target.accountId &&
|
|
||||||
operationIntervalsOverlap(row, target),
|
|
||||||
)
|
|
||||||
.map((row) => ({
|
|
||||||
accountId: target.accountId,
|
|
||||||
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
|
||||||
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const meta = investmentResourceMeta({
|
const meta = investmentResourceMeta({
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
|
resourceKey: `income/${accountId}/selected/${period.key}`,
|
||||||
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
|
|
||||||
dataRevision: saved?.binding ?? 'missing',
|
dataRevision: saved?.binding ?? 'missing',
|
||||||
availability: !known
|
availability: !known
|
||||||
? 'missing'
|
? 'missing'
|
||||||
@@ -571,6 +504,7 @@ export class BrokerageOperationsService {
|
|||||||
fetchedAt,
|
fetchedAt,
|
||||||
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
|
||||||
work,
|
work,
|
||||||
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membership],
|
dependencies: [membership],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serialize),
|
||||||
@@ -613,7 +547,7 @@ export class BrokerageOperationsService {
|
|||||||
return {
|
return {
|
||||||
meta,
|
meta,
|
||||||
items: complete
|
items: complete
|
||||||
? (saved?.rows ?? [])
|
? rows
|
||||||
.filter((row) =>
|
.filter((row) =>
|
||||||
plan.intervals.some(
|
plan.intervals.some(
|
||||||
(interval) =>
|
(interval) =>
|
||||||
@@ -627,6 +561,10 @@ export class BrokerageOperationsService {
|
|||||||
row.payload as TbankOperationItem,
|
row.payload as TbankOperationItem,
|
||||||
row.occurredAt,
|
row.occurredAt,
|
||||||
),
|
),
|
||||||
|
logoName:
|
||||||
|
instruments
|
||||||
|
.get(metadataKey(row.payload as TbankOperationItem))
|
||||||
|
?.data?.brand?.logoName?.trim() || null,
|
||||||
...(accountId === 'all'
|
...(accountId === 'all'
|
||||||
? {
|
? {
|
||||||
account: {
|
account: {
|
||||||
|
|||||||
@@ -1,4 +1,30 @@
|
|||||||
import { operationIntervals, planOperationRead } from './operations-sync-plan';
|
import { confirmedOperationRanges, operationIntervals, planOperationRead } from './operations-sync-plan';
|
||||||
|
|
||||||
|
describe('confirmed operation range projection', () => {
|
||||||
|
it('clips overlaps in source order without merging and respects account and instrument scope', () => {
|
||||||
|
const day = (value: number) => new Date(Date.UTC(2026, 0, value));
|
||||||
|
const target = { accountId: 'a', from: day(2), to: day(5) };
|
||||||
|
const sources = [
|
||||||
|
{ ...target, from: day(3), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(4) },
|
||||||
|
{ ...target, instrumentUid: 'uid' },
|
||||||
|
{ ...target, accountId: 'other' },
|
||||||
|
{ ...target, from: day(5), to: day(6) },
|
||||||
|
{ ...target, from: day(1), to: day(2) },
|
||||||
|
];
|
||||||
|
const before = structuredClone(sources);
|
||||||
|
expect(confirmedOperationRanges([target], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
]);
|
||||||
|
expect(confirmedOperationRanges([{ ...target, instrumentUid: 'uid' }], sources)).toEqual([
|
||||||
|
{ ...target, from: day(3) },
|
||||||
|
{ ...target, to: day(4) },
|
||||||
|
target,
|
||||||
|
]);
|
||||||
|
expect(sources).toEqual(before);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('operations calendar windows', () => {
|
describe('operations calendar windows', () => {
|
||||||
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
it('merges overlaps before splitting leap February and keeps precise Moscow-derived boundaries', () => {
|
||||||
@@ -45,7 +71,7 @@ describe('operation completeness and freshness', () => {
|
|||||||
const asOf = new Date('2026-09-25T12:00:00Z');
|
const asOf = new Date('2026-09-25T12:00:00Z');
|
||||||
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
|
const covered = { accountId: 'a', from, to: asOf, asOf, fetchedAt: asOf };
|
||||||
|
|
||||||
it.each([1, 299, 300])(
|
it.each([1, 3599, 3600])(
|
||||||
'preserves the observed complete end after %s seconds, separately from TTL',
|
'preserves the observed complete end after %s seconds, separately from TTL',
|
||||||
(seconds) => {
|
(seconds) => {
|
||||||
const now = new Date(asOf.valueOf() + seconds * 1000);
|
const now = new Date(asOf.valueOf() + seconds * 1000);
|
||||||
@@ -59,10 +85,10 @@ describe('operation completeness and freshness', () => {
|
|||||||
availability: 'complete',
|
availability: 'complete',
|
||||||
asOf,
|
asOf,
|
||||||
to: asOf,
|
to: asOf,
|
||||||
stale: seconds >= 300,
|
stale: seconds >= 3600,
|
||||||
});
|
});
|
||||||
expect(plan.load).toEqual(
|
expect(plan.load).toEqual(
|
||||||
seconds >= 300 ? [{ accountId: 'a', from, to: now }] : [],
|
[],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -106,11 +106,29 @@ export function uncoveredOperationIntervals(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function confirmedOperationRanges(
|
||||||
|
targets: OperationInterval[],
|
||||||
|
coverage: OperationInterval[],
|
||||||
|
): Omit<OperationInterval, 'instrumentUid'>[] {
|
||||||
|
return targets.flatMap((target) => coverage
|
||||||
|
.filter((row) =>
|
||||||
|
row.accountId === target.accountId &&
|
||||||
|
(!row.instrumentUid || row.instrumentUid === target.instrumentUid) &&
|
||||||
|
row.from < target.to && target.from < row.to,
|
||||||
|
)
|
||||||
|
.map((row) => ({
|
||||||
|
accountId: target.accountId,
|
||||||
|
from: new Date(Math.max(row.from.valueOf(), target.from.valueOf())),
|
||||||
|
to: new Date(Math.min(row.to.valueOf(), target.to.valueOf())),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export type OperationCoverage = OperationInterval & {
|
export type OperationCoverage = OperationInterval & {
|
||||||
asOf: Date;
|
asOf: Date;
|
||||||
fetchedAt: Date;
|
fetchedAt: Date;
|
||||||
};
|
};
|
||||||
const operationsFreshForMs = 300_000;
|
const operationsFreshForMs = 3_600_000;
|
||||||
|
|
||||||
export function planOperationRead(
|
export function planOperationRead(
|
||||||
requested: OperationInterval[],
|
requested: OperationInterval[],
|
||||||
@@ -170,8 +188,9 @@ export function planOperationRead(
|
|||||||
.filter((row) => row.from < row.to),
|
.filter((row) => row.from < row.to),
|
||||||
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
|
fresh.map((row) => ({ ...row, instrumentUid: target.instrumentUid })),
|
||||||
).length > EMPTY_INTERVAL_COUNT;
|
).length > EMPTY_INTERVAL_COUNT;
|
||||||
|
// The mutable tail is owned by the hourly walk; reads only load what is
|
||||||
|
// missing, never re-verify complete or fresh intervals.
|
||||||
if (options.force) load.push(target);
|
if (options.force) load.push(target);
|
||||||
else if (stale) load.push({ ...target, from: monthStart, to: now });
|
|
||||||
else load.push(...missing);
|
else load.push(...missing);
|
||||||
// Completeness is bounded by the observed end, never by the new wall clock.
|
// Completeness is bounded by the observed end, never by the new wall clock.
|
||||||
return {
|
return {
|
||||||
|
|||||||
+43
-46
@@ -392,52 +392,15 @@ export class BrokerageOperationsRepository {
|
|||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: previous.id },
|
||||||
data: {
|
data: {
|
||||||
intent: {
|
intent: extendRunningIntent({
|
||||||
atomic: previousIntent?.atomic ?? publicationAtomic,
|
previous: previousIntent,
|
||||||
atomicIntervals: previousIntent?.atomic
|
publicationAtomic,
|
||||||
? [
|
pending,
|
||||||
...new Set([
|
additions,
|
||||||
...(previousIntent.atomicIntervals ??
|
intervals,
|
||||||
previousIntent.intervals),
|
priority,
|
||||||
...additions.map(intervalKey),
|
previousPriority: previous.priority,
|
||||||
]),
|
}),
|
||||||
]
|
|
||||||
: [],
|
|
||||||
screenIntervals: [
|
|
||||||
...new Set([
|
|
||||||
...(previousIntent?.screenIntervals ??
|
|
||||||
(previous.priority === operationsSyncPriority.screen
|
|
||||||
? (previousIntent?.intervals ?? [])
|
|
||||||
: [])),
|
|
||||||
...(priority === operationsSyncPriority.screen
|
|
||||||
? [...pending, ...additions]
|
|
||||||
.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.map(intervalKey)
|
|
||||||
: []),
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
intervals: (priority < previous.priority
|
|
||||||
? [
|
|
||||||
...pending.filter((item) =>
|
|
||||||
intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
...additions,
|
|
||||||
...pending.filter(
|
|
||||||
(item) =>
|
|
||||||
!intervals.some((target) =>
|
|
||||||
operationIntervalsOverlap(item, target),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: [...pending, ...additions]
|
|
||||||
).map(intervalKey),
|
|
||||||
},
|
|
||||||
priority: Math.min(priority, previous.priority),
|
priority: Math.min(priority, previous.priority),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -919,6 +882,40 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function extendRunningIntent(input: {
|
||||||
|
additions: OperationInterval[];
|
||||||
|
intervals: OperationInterval[];
|
||||||
|
pending: OperationInterval[];
|
||||||
|
previous: OperationsIntent | undefined;
|
||||||
|
previousPriority: number;
|
||||||
|
priority: number;
|
||||||
|
publicationAtomic: boolean;
|
||||||
|
}): OperationsIntent {
|
||||||
|
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||||
|
const overlapsRequested = (item: OperationInterval) =>
|
||||||
|
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||||
|
const retainedScreen = previous?.screenIntervals ??
|
||||||
|
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||||
|
const addedScreen = priority === operationsSyncPriority.screen
|
||||||
|
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||||
|
: [];
|
||||||
|
const ordered = priority < previousPriority
|
||||||
|
? [
|
||||||
|
...pending.filter(overlapsRequested),
|
||||||
|
...additions,
|
||||||
|
...pending.filter((item) => !overlapsRequested(item)),
|
||||||
|
]
|
||||||
|
: [...pending, ...additions];
|
||||||
|
return {
|
||||||
|
atomic: previous?.atomic ?? publicationAtomic,
|
||||||
|
atomicIntervals: previous?.atomic
|
||||||
|
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||||
|
: [],
|
||||||
|
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||||
|
intervals: ordered.map(intervalKey),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function intervalKey(interval: OperationInterval): string {
|
function intervalKey(interval: OperationInterval): string {
|
||||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||||
if (!(interval.from < interval.to)) {
|
if (!(interval.from < interval.to)) {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
|
|||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
|
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, TbankModule],
|
imports: [AuthModule, TbankModule, TbankSyncRepositoryModule],
|
||||||
controllers: [BrokerageOperationsController],
|
controllers: [BrokerageOperationsController],
|
||||||
providers: [
|
providers: [
|
||||||
BrokerageOperationsService,
|
BrokerageOperationsService,
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ import {
|
|||||||
|
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||||
import {
|
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
|
||||||
} from '../../investment-resource.dto';
|
|
||||||
|
|
||||||
const MIN_OPERATIONS_PAGE_SIZE = 1;
|
const MIN_OPERATIONS_PAGE_SIZE = 1;
|
||||||
|
|
||||||
@@ -36,7 +33,7 @@ export const operationCategories = [
|
|||||||
] as const;
|
] as const;
|
||||||
export type OperationCategory = (typeof operationCategories)[number];
|
export type OperationCategory = (typeof operationCategories)[number];
|
||||||
|
|
||||||
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
|
export class BrokerageOperationsQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
|
|
||||||
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api';
|
import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
ForecastRepository,
|
ForecastRepository,
|
||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
} from '../infrastructure/forecast.repository';
|
} from '../infrastructure/forecast.repository';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ForecastService {
|
export class ForecastService {
|
||||||
@@ -18,6 +17,7 @@ export class ForecastService {
|
|||||||
|
|
||||||
async result<T extends object>(input: {
|
async result<T extends object>(input: {
|
||||||
candidate?: T;
|
candidate?: T;
|
||||||
|
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
|
||||||
membershipRevision: number;
|
membershipRevision: number;
|
||||||
portfolio: InvestmentResourceMetaDto;
|
portfolio: InvestmentResourceMetaDto;
|
||||||
revision: string;
|
revision: string;
|
||||||
@@ -73,41 +73,52 @@ export class ForecastService {
|
|||||||
dependencies
|
dependencies
|
||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
const payload = stored ? (stored.payload as T) : null;
|
|
||||||
const data =
|
|
||||||
payload &&
|
|
||||||
'positionIsStale' in payload &&
|
|
||||||
'positionAt' in payload &&
|
|
||||||
typeof payload.positionAt === 'string'
|
|
||||||
? {
|
|
||||||
...payload,
|
|
||||||
positionIsStale: tbankSourceNeedsRefresh(
|
|
||||||
'portfolio',
|
|
||||||
new Date(payload.positionAt),
|
|
||||||
new Date(Date.now()),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: payload;
|
|
||||||
return {
|
return {
|
||||||
data,
|
data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: input.scopeKey,
|
resourceKey: input.scopeKey,
|
||||||
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
|
||||||
availability: stored
|
availability: stored
|
||||||
? 'complete'
|
? 'complete'
|
||||||
: dependencies.some(
|
: dependencies.some(
|
||||||
(dependency) => dependency.availability !== 'missing',
|
(dependency) => dependency.completeness === 'complete',
|
||||||
)
|
)
|
||||||
? 'partial'
|
? 'partial'
|
||||||
: 'missing',
|
: 'missing',
|
||||||
fetchedAt: first ? new Date(first) : null,
|
fetchedAt: first ? new Date(first) : null,
|
||||||
freshForMs:
|
freshForMs: Number.POSITIVE_INFINITY,
|
||||||
data && 'positionIsStale' in data && data.positionIsStale === true
|
|
||||||
? STALE_FRESHNESS_MS
|
|
||||||
: Number.POSITIVE_INFINITY,
|
|
||||||
work: work?.id ? { ...work, id: work.id } : null,
|
work: work?.id ? { ...work, id: work.id } : null,
|
||||||
|
lastAttempt: input.lastAttempt,
|
||||||
dependencies,
|
dependencies,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
|
||||||
|
if (!scopeKey.startsWith('events/')) return payload;
|
||||||
|
try {
|
||||||
|
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
|
const value = payload as T & {
|
||||||
|
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
|
||||||
|
incomeTotalsByCurrency?: { currency: string; value: string }[];
|
||||||
|
};
|
||||||
|
if (!value.events || !value.incomeTotalsByCurrency) return payload;
|
||||||
|
const events = value.events.filter((event) => event.date >= from && event.date <= to);
|
||||||
|
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||||
|
for (const event of events) {
|
||||||
|
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
|
||||||
|
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
|
||||||
|
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
events,
|
||||||
|
incomeTotalsByCurrency: [...totals]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ServiceUnavailableException } from '@nestjs/common';
|
import assert from 'node:assert/strict';
|
||||||
import { setImmediate as immediate } from 'node:timers/promises';
|
|
||||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
@@ -23,12 +22,11 @@ describe('persistent portfolio projection', () => {
|
|||||||
service = new PortfolioService(
|
service = new PortfolioService(
|
||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
{ set: jest.fn() } as never,
|
|
||||||
metadata as never,
|
metadata as never,
|
||||||
repository as never,
|
repository as never,
|
||||||
{ request: jest.fn().mockResolvedValue(null) } as never,
|
{ request: jest.fn().mockResolvedValue(null) } as never,
|
||||||
{ event: jest.fn() } as never,
|
{ event: jest.fn() } as never,
|
||||||
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never,
|
{ hourly: jest.fn().mockResolvedValue(null) } as never,
|
||||||
);
|
);
|
||||||
access.local.mockResolvedValue({
|
access.local.mockResolvedValue({
|
||||||
revision: 'private',
|
revision: 'private',
|
||||||
@@ -43,6 +41,7 @@ describe('persistent portfolio projection', () => {
|
|||||||
accounts: [],
|
accounts: [],
|
||||||
work: [],
|
work: [],
|
||||||
});
|
});
|
||||||
|
repository.publish.mockResolvedValue(true);
|
||||||
metadata.savedMany.mockResolvedValue(new Map());
|
metadata.savedMany.mockResolvedValue(new Map());
|
||||||
metadata.request.mockResolvedValue(null);
|
metadata.request.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
@@ -98,14 +97,9 @@ describe('persistent portfolio projection', () => {
|
|||||||
],
|
],
|
||||||
work: [],
|
work: [],
|
||||||
});
|
});
|
||||||
await service.read('user', 'a');
|
const handle = await service.request('user', 'private', 'a');
|
||||||
for (
|
assert(handle);
|
||||||
let index = 0;
|
await handle.done;
|
||||||
index < 10 && !repository.publish.mock.calls.length;
|
|
||||||
index += 1
|
|
||||||
) {
|
|
||||||
await immediate();
|
|
||||||
}
|
|
||||||
const snapshot = (
|
const snapshot = (
|
||||||
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
|
||||||
)[0][2];
|
)[0][2];
|
||||||
@@ -129,6 +123,42 @@ describe('persistent portfolio projection', () => {
|
|||||||
]);
|
]);
|
||||||
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
expect(snapshot.cashBalances).toEqual([{ currency: 'RUB', value: '1' }]);
|
||||||
});
|
});
|
||||||
|
it('keeps net-zero cash groups, missing valuations and incompatible currencies distinct', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ quantity: { units: '1' }, currentPrice: { units: '3' } },
|
||||||
|
{
|
||||||
|
quantity: { units: '1' },
|
||||||
|
currentPrice: { currency: 'USD', units: '3' },
|
||||||
|
averagePositionPrice: { currency: 'RUB', units: '2' },
|
||||||
|
},
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [
|
||||||
|
{ currency: 'rub', units: '2' },
|
||||||
|
{ currency: 'RUB', units: '-2' },
|
||||||
|
{ units: '99' },
|
||||||
|
{ currency: 'EUR', units: '1' },
|
||||||
|
] });
|
||||||
|
const handle = await service.request('user', 'private', 'a');
|
||||||
|
assert(handle);
|
||||||
|
await handle.done;
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.cashBalances).toEqual([
|
||||||
|
{ currency: 'RUB', value: '0' },
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.totalsByCurrency).toEqual([
|
||||||
|
{ currency: 'EUR', value: '1' },
|
||||||
|
{ currency: 'USD', value: '3' },
|
||||||
|
]);
|
||||||
|
expect(snapshot.positions[0].currentValue).toBeNull();
|
||||||
|
expect(snapshot.positions[1].absoluteReturn).toBeNull();
|
||||||
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
|
});
|
||||||
|
|
||||||
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
|
||||||
const snapshot: PortfolioDto = {
|
const snapshot: PortfolioDto = {
|
||||||
lastSuccessfulAt: membershipAt.toISOString(),
|
lastSuccessfulAt: membershipAt.toISOString(),
|
||||||
@@ -155,10 +185,11 @@ describe('persistent portfolio projection', () => {
|
|||||||
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
|
accounts: [account, { ...account, accountId: 'b', portfolio: null }],
|
||||||
work: [],
|
work: [],
|
||||||
});
|
});
|
||||||
const partial = await service.read('user', 'all', true);
|
const partial = await service.read('user', 'all');
|
||||||
expect(partial.data).toBeNull();
|
expect(partial.data).toBeNull();
|
||||||
expect(partial.meta.availability).toBe('partial');
|
expect(partial.meta.availability).toBe('temporarily-unavailable');
|
||||||
const saved = await service.saved('user', 'all', true);
|
expect(partial.meta.completeness).toBe('incomplete');
|
||||||
|
const saved = await service.saved('user', 'all');
|
||||||
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
|
||||||
snapshot.totalsByCurrency,
|
snapshot.totalsByCurrency,
|
||||||
);
|
);
|
||||||
@@ -176,7 +207,7 @@ describe('persistent portfolio projection', () => {
|
|||||||
],
|
],
|
||||||
work: [],
|
work: [],
|
||||||
});
|
});
|
||||||
const complete = await service.read('user', 'all', true);
|
const complete = await service.read('user', 'all');
|
||||||
expect(complete.data?.totalsByCurrency).toEqual([
|
expect(complete.data?.totalsByCurrency).toEqual([
|
||||||
{ currency: 'RUB', value: '2.000000002' },
|
{ currency: 'RUB', value: '2.000000002' },
|
||||||
]);
|
]);
|
||||||
@@ -184,15 +215,4 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(complete.data?.isStale).toBe(true);
|
expect(complete.data?.isStale).toBe(true);
|
||||||
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
expect(tbank.getPortfolio).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('keeps pending data unavailable to internal financial consumers', async () => {
|
|
||||||
repository.read.mockResolvedValue({
|
|
||||||
membershipAt: null,
|
|
||||||
membershipRevision: 0,
|
|
||||||
accounts: [],
|
|
||||||
work: [],
|
|
||||||
});
|
|
||||||
await expect(service.getAll('user')).rejects.toBeInstanceOf(
|
|
||||||
ServiceUnavailableException,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,36 +1,26 @@
|
|||||||
import {
|
import {
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
|
||||||
add,
|
|
||||||
decimalFromString,
|
|
||||||
formatDecimal,
|
|
||||||
isZero,
|
|
||||||
multiply,
|
|
||||||
subtract,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { RequestContextService } from '../../../../common/logging/request-context.service';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
type MetadataIdentity,
|
|
||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankInstrument,
|
|
||||||
TbankMembershipService,
|
TbankMembershipService,
|
||||||
TbankMoney,
|
type TbankMoney,
|
||||||
|
type TbankPortfolio,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { CandleService } from '../../instruments/instruments.api';
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
hourlyAttempt,
|
||||||
|
TbankSyncRepository,
|
||||||
|
} from '../../tbank-sync/tbank-sync.repository';
|
||||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
|
||||||
import {
|
import {
|
||||||
@@ -38,87 +28,35 @@ import {
|
|||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { totalsByCurrency } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
import {
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
PortfolioDto,
|
|
||||||
PortfolioPositionDto,
|
|
||||||
} from '../presentation/portfolio.dto';
|
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
const NO_ACCOUNTS = 0;
|
||||||
|
|
||||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
const types: Record<string, string> = {
|
|
||||||
share: 'STOCK',
|
|
||||||
bond: 'BOND',
|
|
||||||
etf: 'FUND',
|
|
||||||
currency: 'CURRENCY',
|
|
||||||
futures: 'FUTURE',
|
|
||||||
option: 'OPTION',
|
|
||||||
sp: 'STRUCTURED_NOTE',
|
|
||||||
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class PortfolioService {
|
export class PortfolioService {
|
||||||
private readonly processRunId = randomUUID();
|
private readonly processRunId = randomUUID();
|
||||||
private readonly active = new Set<string>();
|
|
||||||
private readonly pending = new Map<string, Promise<void>>();
|
private readonly pending = new Map<string, Promise<void>>();
|
||||||
private reservations: Promise<unknown> = Promise.resolve();
|
private reservations: Promise<unknown> = Promise.resolve();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly requestContext: RequestContextService,
|
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: PortfolioRepository,
|
private readonly repository: PortfolioRepository,
|
||||||
private readonly membership: TbankMembershipService,
|
private readonly membership: TbankMembershipService,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
private readonly candles: CandleService,
|
private readonly sync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async saved(userId: string, accountId: string, observe = false) {
|
// Local reads never start upstream work; the hourly walk owns refreshes.
|
||||||
this.requestContext.set({ userId, brokerageAccountId: accountId });
|
async saved(userId: string, accountId: string) {
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
if (!observe) {
|
|
||||||
const discovery = await this.membership.request(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
);
|
|
||||||
if (discovery) {
|
|
||||||
void discovery.done
|
|
||||||
.then(async () => {
|
|
||||||
const current = await this.access.local(userId, accountId);
|
|
||||||
if (current.revision !== connection.revision) return;
|
|
||||||
for (const account of current.accounts.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
)) {
|
|
||||||
await this.queueSnapshot(
|
|
||||||
userId,
|
|
||||||
current.revision,
|
|
||||||
account.accountId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() =>
|
|
||||||
this.logger.event(
|
|
||||||
'warn',
|
|
||||||
'tbank.portfolio.discovery_not_available',
|
|
||||||
{ userId },
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
for (const account of (discovery ? [] : connection.accounts).filter(
|
|
||||||
(account) => accountId === 'all' || account.accountId === accountId,
|
|
||||||
)) {
|
|
||||||
await this.queueSnapshot(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
account.accountId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const saved = await this.repository.read(userId, connection.revision);
|
const saved = await this.repository.read(userId, connection.revision);
|
||||||
|
const hourly = await this.sync.hourly(userId);
|
||||||
if (
|
if (
|
||||||
saved?.membershipAt &&
|
saved?.membershipAt &&
|
||||||
accountId !== 'all' &&
|
accountId !== 'all' &&
|
||||||
@@ -126,48 +64,6 @@ export class PortfolioService {
|
|||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
if (!observe) {
|
|
||||||
const identities = new Map<string, MetadataIdentity>();
|
|
||||||
for (const account of (saved?.accounts ?? []).filter(
|
|
||||||
(account) => accountId === 'all' || account.accountId === accountId,
|
|
||||||
)) {
|
|
||||||
const snapshot = account.portfolio?.payload as unknown as
|
|
||||||
PortfolioDto | undefined;
|
|
||||||
for (const position of snapshot?.positions ?? []) {
|
|
||||||
identities.set(metadataKey(position), position);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const savedMetadata = await this.metadata.savedMany([
|
|
||||||
...identities.values(),
|
|
||||||
]);
|
|
||||||
for (const identity of identities.values()) {
|
|
||||||
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
|
|
||||||
await this.metadata.request(userId, connection.revision, identity);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!observe) {
|
|
||||||
void this.candles
|
|
||||||
.prefetch(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
(saved?.accounts ?? [])
|
|
||||||
.filter(
|
|
||||||
(account) =>
|
|
||||||
accountId === 'all' || account.accountId === accountId,
|
|
||||||
)
|
|
||||||
.flatMap(
|
|
||||||
(account) =>
|
|
||||||
(
|
|
||||||
account.portfolio?.payload as unknown as
|
|
||||||
PortfolioDto | undefined
|
|
||||||
)?.positions ?? [],
|
|
||||||
),
|
|
||||||
)
|
|
||||||
.catch(() =>
|
|
||||||
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const membershipMeta = investmentResourceMeta({
|
const membershipMeta = investmentResourceMeta({
|
||||||
resourceKey: 'accounts/membership',
|
resourceKey: 'accounts/membership',
|
||||||
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
|
||||||
@@ -175,8 +71,10 @@ export class PortfolioService {
|
|||||||
fetchedAt: saved?.membershipAt ?? null,
|
fetchedAt: saved?.membershipAt ?? null,
|
||||||
freshForMs: membershipFreshForMs,
|
freshForMs: membershipFreshForMs,
|
||||||
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
|
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
|
||||||
|
lastAttempt: hourlyAttempt(hourly, 'membership'),
|
||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
|
hourly,
|
||||||
membership: membershipMeta,
|
membership: membershipMeta,
|
||||||
accounts: (saved?.accounts ?? [])
|
accounts: (saved?.accounts ?? [])
|
||||||
.filter(
|
.filter(
|
||||||
@@ -206,16 +104,21 @@ export class PortfolioService {
|
|||||||
saved?.work.find(
|
saved?.work.find(
|
||||||
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
|
(work) => work.scopeKey === portfolioWorkKey(account.accountId),
|
||||||
) ?? null,
|
) ?? null,
|
||||||
|
lastAttempt: hourlyAttempt(hourly, 'portfolio', account.accountId),
|
||||||
}),
|
}),
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async read(userId: string, accountId: string, observe = false) {
|
async read(userId: string, accountId: string) {
|
||||||
const saved = await this.saved(userId, accountId, observe);
|
const { hourly, ...saved } = await this.saved(userId, accountId);
|
||||||
const missing =
|
const missing =
|
||||||
saved.membership.availability === 'missing' ||
|
saved.membership.completeness === 'incomplete' ||
|
||||||
saved.accounts.some((account) => !account.snapshot);
|
(saved.accounts.length > NO_ACCOUNTS && saved.accounts.some((account) => !account.snapshot));
|
||||||
|
const available =
|
||||||
|
!missing &&
|
||||||
|
(saved.membership.availability === 'available' ||
|
||||||
|
saved.accounts.some((account) => account.snapshot));
|
||||||
const metadata = await this.metadata.savedMany(
|
const metadata = await this.metadata.savedMany(
|
||||||
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
|
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
|
||||||
);
|
);
|
||||||
@@ -228,7 +131,7 @@ export class PortfolioService {
|
|||||||
dataRevision: source.dataRevision,
|
dataRevision: source.dataRevision,
|
||||||
availability: 'complete',
|
availability: 'complete',
|
||||||
fetchedAt: source.fetchedAt,
|
fetchedAt: source.fetchedAt,
|
||||||
freshForMs: tbankSourceTtlMs.metadata,
|
freshForMs: portfolioFreshForMs,
|
||||||
work: null,
|
work: null,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
@@ -264,6 +167,11 @@ export class PortfolioService {
|
|||||||
]
|
]
|
||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
|
let availability: 'available' | 'complete' | 'missing' | 'partial';
|
||||||
|
if (available) availability = 'available';
|
||||||
|
else if (!missing) availability = 'complete';
|
||||||
|
else if (portfolios.length > NO_ACCOUNTS) availability = 'partial';
|
||||||
|
else availability = 'missing';
|
||||||
const lastSuccessfulAt =
|
const lastSuccessfulAt =
|
||||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||||
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
|
||||||
@@ -307,16 +215,13 @@ export class PortfolioService {
|
|||||||
]),
|
]),
|
||||||
metadataDependencies.map((dependency) => dependency.dataRevision),
|
metadataDependencies.map((dependency) => dependency.dataRevision),
|
||||||
]),
|
]),
|
||||||
availability: missing
|
availability,
|
||||||
? portfolios.length
|
|
||||||
? 'partial'
|
|
||||||
: 'missing'
|
|
||||||
: 'complete',
|
|
||||||
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
|
||||||
freshForMs: portfolios.length
|
freshForMs: portfolios.length
|
||||||
? portfolioFreshForMs
|
? portfolioFreshForMs
|
||||||
: membershipFreshForMs,
|
: membershipFreshForMs,
|
||||||
work: work.id ? { ...work, id: work.id } : null,
|
work: work.id ? { ...work, id: work.id } : null,
|
||||||
|
lastAttempt: hourlyAttempt(hourly, 'portfolio', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [
|
dependencies: [
|
||||||
saved.membership,
|
saved.membership,
|
||||||
...saved.accounts.map((account) => account.meta),
|
...saved.accounts.map((account) => account.meta),
|
||||||
@@ -342,7 +247,7 @@ export class PortfolioService {
|
|||||||
revision,
|
revision,
|
||||||
accountId,
|
accountId,
|
||||||
this.processRunId,
|
this.processRunId,
|
||||||
this.active,
|
this.pending,
|
||||||
force,
|
force,
|
||||||
);
|
);
|
||||||
if (!requested) return null;
|
if (!requested) return null;
|
||||||
@@ -353,7 +258,6 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
const { id, attemptId } = requested.work;
|
const { id, attemptId } = requested.work;
|
||||||
this.active.add(attemptId);
|
|
||||||
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
const done = new Promise<void>((resolve) => setImmediate(resolve))
|
||||||
.then(async () => {
|
.then(async () => {
|
||||||
const attempt = { id, attemptId, userId, revision };
|
const attempt = { id, attemptId, userId, revision };
|
||||||
@@ -377,13 +281,6 @@ export class PortfolioService {
|
|||||||
for (const position of snapshot.positions) {
|
for (const position of snapshot.positions) {
|
||||||
await this.metadata.request(userId, revision, position);
|
await this.metadata.request(userId, revision, position);
|
||||||
}
|
}
|
||||||
if (!force) {
|
|
||||||
await this.candles.prefetch(
|
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
snapshot.positions,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
@@ -400,7 +297,6 @@ export class PortfolioService {
|
|||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.active.delete(attemptId);
|
|
||||||
this.pending.delete(attemptId);
|
this.pending.delete(attemptId);
|
||||||
});
|
});
|
||||||
this.pending.set(attemptId, done);
|
this.pending.set(attemptId, done);
|
||||||
@@ -410,122 +306,12 @@ export class PortfolioService {
|
|||||||
return reserved;
|
return reserved;
|
||||||
}
|
}
|
||||||
|
|
||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
|
||||||
const result = await this.read(userId, accountId);
|
|
||||||
if (!result.data) {
|
|
||||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
|
||||||
}
|
|
||||||
return result.data;
|
|
||||||
}
|
|
||||||
|
|
||||||
getAll(userId: string): Promise<PortfolioDto> {
|
|
||||||
return this.get(userId, 'all');
|
|
||||||
}
|
|
||||||
|
|
||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: {
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
averagePositionPrice?: TbankMoney;
|
|
||||||
currentPrice?: TbankMoney;
|
|
||||||
figi?: string;
|
|
||||||
instrumentType?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
quantity?: { nano?: number | string; units?: number | string };
|
|
||||||
}[],
|
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
): Promise<PortfolioDto> {
|
): Promise<PortfolioDto> {
|
||||||
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
|
||||||
const savedMetadata = await this.metadata.savedMany(upstream);
|
const savedMetadata = await this.metadata.savedMany(upstream);
|
||||||
const positions = upstream.map((position) =>
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
||||||
this.position(
|
|
||||||
position,
|
|
||||||
savedMetadata.get(metadataKey(position))?.data ?? null,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const cash = new Map<string, ReturnType<typeof decimal>>();
|
|
||||||
for (const value of money) {
|
|
||||||
const currency = value.currency?.trim().toUpperCase();
|
|
||||||
const amount = decimal(value);
|
|
||||||
if (!currency || isZero(amount)) continue;
|
|
||||||
cash.set(currency, add(cash.get(currency) ?? decimal({}), amount));
|
|
||||||
}
|
|
||||||
const cashBalances = [...cash].map(([currency, value]) => ({
|
|
||||||
currency,
|
|
||||||
value: formatDecimal(value),
|
|
||||||
}));
|
|
||||||
const normalizedPositions = positions.filter(
|
|
||||||
(position): position is PortfolioPositionDto => position !== null,
|
|
||||||
);
|
|
||||||
return {
|
|
||||||
lastSuccessfulAt,
|
|
||||||
isStale: false,
|
|
||||||
totalsByCurrency: totalsByCurrency(upstream, money),
|
|
||||||
positions: normalizedPositions,
|
|
||||||
cashBalances,
|
|
||||||
structure: portfolioStructure(normalizedPositions, cashBalances),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private position(
|
|
||||||
position: Parameters<PortfolioService['normalize']>[0][number],
|
|
||||||
metadata: null | TbankInstrument,
|
|
||||||
): null | PortfolioPositionDto {
|
|
||||||
const quantity = decimal(position.quantity ?? {});
|
|
||||||
if (isZero(quantity)) return null;
|
|
||||||
const figi = position.figi?.trim() || null;
|
|
||||||
const instrumentUid = position.instrumentUid?.trim() || null;
|
|
||||||
const currentPrice = this.money(position.currentPrice);
|
|
||||||
const averagePurchasePrice = this.money(position.averagePositionPrice);
|
|
||||||
const currentValue = currentPrice && this.value(quantity, currentPrice);
|
|
||||||
const absoluteReturn =
|
|
||||||
currentPrice &&
|
|
||||||
averagePurchasePrice &&
|
|
||||||
currentPrice.currency === averagePurchasePrice.currency
|
|
||||||
? {
|
|
||||||
currency: currentPrice.currency,
|
|
||||||
value: formatDecimal(
|
|
||||||
multiply(
|
|
||||||
subtract(
|
|
||||||
decimalFromString(currentPrice.value),
|
|
||||||
decimalFromString(averagePurchasePrice.value),
|
|
||||||
),
|
|
||||||
quantity,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
figi,
|
|
||||||
instrumentUid,
|
|
||||||
displayName:
|
|
||||||
metadata?.name?.trim() ||
|
|
||||||
metadata?.ticker?.trim() ||
|
|
||||||
figi ||
|
|
||||||
'Unknown instrument',
|
|
||||||
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
|
||||||
quantity: formatDecimal(quantity),
|
|
||||||
currentValue,
|
|
||||||
averagePurchasePrice,
|
|
||||||
absoluteReturn,
|
|
||||||
isin: metadata?.isin?.trim() || null,
|
|
||||||
ticker: metadata?.ticker?.trim() || null,
|
|
||||||
logoName: metadata?.brand?.logoName?.trim() || null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private value(
|
|
||||||
quantity: ReturnType<typeof decimal>,
|
|
||||||
price: MoneyDto,
|
|
||||||
): MoneyDto {
|
|
||||||
return {
|
|
||||||
currency: price.currency,
|
|
||||||
value: formatDecimal(multiply(quantity, decimalFromString(price.value))),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private money(value: TbankMoney | undefined): MoneyDto | null {
|
|
||||||
const currency = value?.currency?.trim().toUpperCase();
|
|
||||||
return currency && value
|
|
||||||
? { currency, value: formatDecimal(decimal(value)) }
|
|
||||||
: null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,24 +44,39 @@ export class ForecastRepository {
|
|||||||
data: { membershipRevision: { increment: 0 } },
|
data: { membershipRevision: { increment: 0 } },
|
||||||
});
|
});
|
||||||
if (!current.count) return null;
|
if (!current.count) return null;
|
||||||
}
|
} else if (!(await tx.tbankConnection.findFirst({
|
||||||
const connection = await tx.tbankConnection.findFirst({
|
where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
|
||||||
where: {
|
}))) return null;
|
||||||
...owner,
|
|
||||||
membershipRevision: input.membershipRevision,
|
|
||||||
invalidToken: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!connection) return null;
|
|
||||||
const key = { ...owner, scopeKey: input.scopeKey };
|
const key = { ...owner, scopeKey: input.scopeKey };
|
||||||
const previous = await tx.tbankForecast.findUnique({
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
});
|
});
|
||||||
|
const requestedWindow = eventsWindow(input.scopeKey);
|
||||||
|
const saved = previous ?? (requestedWindow
|
||||||
|
? (
|
||||||
|
await tx.tbankForecast.findMany({
|
||||||
|
where: {
|
||||||
|
...owner,
|
||||||
|
scopeKey: { startsWith: 'events/' },
|
||||||
|
membershipRevision: input.membershipRevision,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).find((forecast) => {
|
||||||
|
const window = eventsWindow(forecast.scopeKey);
|
||||||
|
return (
|
||||||
|
window &&
|
||||||
|
window.accountId === requestedWindow.accountId &&
|
||||||
|
window.from <= requestedWindow.to &&
|
||||||
|
requestedWindow.from <= window.to
|
||||||
|
);
|
||||||
|
})
|
||||||
|
: null);
|
||||||
const fallback =
|
const fallback =
|
||||||
previous?.membershipRevision === input.membershipRevision
|
saved?.membershipRevision === input.membershipRevision
|
||||||
? previous
|
? saved
|
||||||
: null;
|
: null;
|
||||||
if (!input.candidate) return fallback;
|
if (!input.candidate) return fallback;
|
||||||
|
if (!previous && fallback) return fallback;
|
||||||
const portfolios = await tx.tbankPortfolio.findMany({
|
const portfolios = await tx.tbankPortfolio.findMany({
|
||||||
where: {
|
where: {
|
||||||
...owner,
|
...owner,
|
||||||
@@ -160,3 +175,13 @@ export class ForecastRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function eventsWindow(scopeKey: string) {
|
||||||
|
if (!scopeKey.startsWith('events/')) return null;
|
||||||
|
try {
|
||||||
|
const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
|
return { accountId, from, to };
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export class PortfolioRepository {
|
|||||||
revision: string,
|
revision: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
processRunId: string,
|
processRunId: string,
|
||||||
active: ReadonlySet<string>,
|
pending: ReadonlyMap<string, Promise<void>>,
|
||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
@@ -69,21 +69,27 @@ export class PortfolioRepository {
|
|||||||
const snapshot = await tx.tbankPortfolio.findUnique({
|
const snapshot = await tx.tbankPortfolio.findUnique({
|
||||||
where: { userId_revision_accountId: { userId, revision, accountId } },
|
where: { userId_revision_accountId: { userId, revision, accountId } },
|
||||||
});
|
});
|
||||||
|
const portfolioWorkScope = {
|
||||||
|
userId,
|
||||||
|
revision,
|
||||||
|
scopeKey: portfolioWorkKey(accountId),
|
||||||
|
};
|
||||||
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
|
where: { userId_revision_scopeKey: portfolioWorkScope },
|
||||||
|
});
|
||||||
if (
|
if (
|
||||||
!force &&
|
!force &&
|
||||||
snapshot &&
|
snapshot &&
|
||||||
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date())
|
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) &&
|
||||||
|
previous?.state !== 'failed'
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) };
|
const scope = portfolioWorkScope;
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
|
||||||
where: { userId_revision_scopeKey: scope },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
previous?.state === 'running' &&
|
previous?.state === 'running' &&
|
||||||
previous.processRunId === processRunId &&
|
previous.processRunId === processRunId &&
|
||||||
active.has(previous.attemptId)
|
pending.has(previous.attemptId)
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
@@ -139,6 +145,41 @@ export class PortfolioRepository {
|
|||||||
create: { ...scope, ...data },
|
create: { ...scope, ...data },
|
||||||
update: { ...data, dataRevision: { increment: 1 } },
|
update: { ...data, dataRevision: { increment: 1 } },
|
||||||
});
|
});
|
||||||
|
// Value history points require a fully valued snapshot; the attempt
|
||||||
|
// identity deduplicates repeated or late publications of one attempt.
|
||||||
|
if (
|
||||||
|
snapshot.totalsByCurrency.length > Number.MIN_VALUE &&
|
||||||
|
snapshot.positions.every((position) => position.currentValue !== null)
|
||||||
|
) {
|
||||||
|
await tx.tbankPortfolioValueHistory.upsert({
|
||||||
|
where: {
|
||||||
|
userId_accountId: { userId: attempt.userId, accountId },
|
||||||
|
},
|
||||||
|
create: { userId: attempt.userId, accountId },
|
||||||
|
update: { deletedAt: null },
|
||||||
|
});
|
||||||
|
for (const { currency, value } of snapshot.totalsByCurrency) {
|
||||||
|
await tx.tbankPortfolioValuePoint.upsert({
|
||||||
|
where: {
|
||||||
|
userId_accountId_attemptId_currency: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
accountId,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
currency,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: attempt.userId,
|
||||||
|
accountId,
|
||||||
|
attemptId: attempt.attemptId,
|
||||||
|
currency,
|
||||||
|
value,
|
||||||
|
capturedAt: data.fetchedAt,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,110 @@
|
|||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '../../../../common/money.types';
|
||||||
|
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
|
subtract,
|
||||||
} from '../../../../common/decimal';
|
} from '../../../../common/decimal';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
|
metadataKey,
|
||||||
|
type TbankInstrument,
|
||||||
|
type TbankMoney,
|
||||||
TbankPortfolio,
|
TbankPortfolio,
|
||||||
TbankPositions,
|
TbankPositions,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||||
|
|
||||||
|
const types: Record<string, string> = {
|
||||||
|
share: 'STOCK',
|
||||||
|
bond: 'BOND',
|
||||||
|
etf: 'FUND',
|
||||||
|
currency: 'CURRENCY',
|
||||||
|
futures: 'FUTURE',
|
||||||
|
option: 'OPTION',
|
||||||
|
sp: 'STRUCTURED_NOTE',
|
||||||
|
dfa: 'DIGITAL_FINANCIAL_ASSET',
|
||||||
|
};
|
||||||
|
|
||||||
|
function mapPortfolio(
|
||||||
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
|
cash: TbankMoney[],
|
||||||
|
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
|
||||||
|
lastSuccessfulAt: string,
|
||||||
|
): PortfolioDto {
|
||||||
|
const positions = upstream
|
||||||
|
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
|
||||||
|
.filter((position): position is PortfolioPositionDto => position !== null);
|
||||||
|
const balances = new Map<string, ReturnType<typeof decimal>>();
|
||||||
|
for (const value of cash) {
|
||||||
|
const currency = value.currency?.trim().toUpperCase();
|
||||||
|
const amount = decimal(value);
|
||||||
|
if (!currency || isZero(amount)) continue;
|
||||||
|
balances.set(currency, add(balances.get(currency) ?? decimal({}), amount));
|
||||||
|
}
|
||||||
|
const cashBalances = [...balances].map(([currency, value]) => ({
|
||||||
|
currency,
|
||||||
|
value: formatDecimal(value),
|
||||||
|
}));
|
||||||
|
return {
|
||||||
|
lastSuccessfulAt,
|
||||||
|
isStale: false,
|
||||||
|
totalsByCurrency: totalsByCurrency(upstream, cash),
|
||||||
|
positions,
|
||||||
|
cashBalances,
|
||||||
|
structure: portfolioStructure(positions, cashBalances),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapPosition(
|
||||||
|
position: NonNullable<TbankPortfolio['positions']>[number],
|
||||||
|
metadata: null | TbankInstrument,
|
||||||
|
): null | PortfolioPositionDto {
|
||||||
|
const quantity = decimal(position.quantity ?? {});
|
||||||
|
if (isZero(quantity)) return null;
|
||||||
|
const figi = position.figi?.trim() || null;
|
||||||
|
const instrumentUid = position.instrumentUid?.trim() || null;
|
||||||
|
const currentPrice = money(position.currentPrice);
|
||||||
|
const averagePurchasePrice = money(position.averagePositionPrice);
|
||||||
|
const currentValue = currentPrice && {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(quantity, decimalFromString(currentPrice.value))),
|
||||||
|
};
|
||||||
|
const absoluteReturn =
|
||||||
|
currentPrice && averagePurchasePrice && currentPrice.currency === averagePurchasePrice.currency
|
||||||
|
? {
|
||||||
|
currency: currentPrice.currency,
|
||||||
|
value: formatDecimal(multiply(
|
||||||
|
subtract(decimalFromString(currentPrice.value), decimalFromString(averagePurchasePrice.value)),
|
||||||
|
quantity,
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
figi,
|
||||||
|
instrumentUid,
|
||||||
|
displayName: metadata?.name?.trim() || metadata?.ticker?.trim() || figi || 'Unknown instrument',
|
||||||
|
type: types[position.instrumentType?.toLowerCase() ?? ''] ?? 'OTHER',
|
||||||
|
quantity: formatDecimal(quantity),
|
||||||
|
currentValue,
|
||||||
|
averagePurchasePrice,
|
||||||
|
absoluteReturn,
|
||||||
|
isin: metadata?.isin?.trim() || null,
|
||||||
|
ticker: metadata?.ticker?.trim() || null,
|
||||||
|
logoName: metadata?.brand?.logoName?.trim() || null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function money(value: TbankMoney | undefined): Money | null {
|
||||||
|
const currency = value?.currency?.trim().toUpperCase();
|
||||||
|
return currency && value
|
||||||
|
? { currency, value: formatDecimal(decimal(value)) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
function totalsByCurrency(
|
function totalsByCurrency(
|
||||||
positions: NonNullable<TbankPortfolio['positions']>,
|
positions: NonNullable<TbankPortfolio['positions']>,
|
||||||
@@ -44,4 +138,4 @@ function totalsByCurrency(
|
|||||||
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
.map(([currency, value]) => ({ currency, value: formatDecimal(value) }));
|
||||||
}
|
}
|
||||||
|
|
||||||
export { totalsByCurrency };
|
export { mapPortfolio, totalsByCurrency };
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user