Compare commits
80
Commits
v2.0.0
...
a2b5306a4c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a2b5306a4c | ||
|
|
812b989130 | ||
|
|
02e0d560c8 | ||
|
|
fa0a6e5603 | ||
|
|
bade5fae1c | ||
|
|
1a5de9bebd | ||
|
|
aeb4647fda | ||
|
|
169bee1da5 | ||
|
|
554410a271 | ||
|
|
a80bbbf1e2 | ||
|
|
a18d9dbdc8 | ||
|
|
17432dfee3 | ||
|
|
5e84e1e5f1 | ||
|
|
e350146749 | ||
|
|
99b137ed0b | ||
|
|
d37fa2c903 | ||
|
|
5295945ab2 | ||
|
|
22668da3fa | ||
|
|
2325b63883 | ||
|
|
f2843e30bd | ||
|
|
97fc41fa29 | ||
|
|
3dcd1a69a6 | ||
|
|
8503429b46 | ||
|
|
0dff4b4377 | ||
|
|
a250142a86 | ||
|
|
0a0e70b273 | ||
|
|
449cfcd6c5 | ||
|
|
966a1f793a | ||
|
|
d1f6969dc7 | ||
|
|
b20080ac85 | ||
|
|
32622c1dad | ||
|
|
4f485ab0e9 | ||
|
|
8a6f60d965 | ||
|
|
e03fad622b | ||
|
|
774e58ef80 | ||
|
|
702302126b | ||
|
|
ec6d9a2639 | ||
|
|
7bc7d8ddec | ||
|
|
9e4f9d9cab | ||
|
|
a93f2d55e7 | ||
|
|
96d4de4195 | ||
|
|
c789e33c41 | ||
|
|
2ad3517d87 | ||
|
|
0d7839cf79 | ||
|
|
6975853656 | ||
|
|
14bcb9da5f | ||
|
|
b90cedd580 | ||
|
|
da2e1d0781 | ||
|
|
bbb5afff84 | ||
|
|
285dd08472 | ||
|
|
1033d2114e | ||
|
|
203cc72cb2 | ||
|
|
9b7e5e1215 | ||
|
|
31b0e15579 | ||
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba | ||
|
|
b9b76bab91 | ||
|
|
fa57a201fd | ||
|
|
96a442253e | ||
|
|
c5248dbefc | ||
|
|
c7d060023b | ||
|
|
a31e8d2c4d | ||
|
|
2f7c15205f | ||
|
|
d7895f287e | ||
|
|
bcd5cb3dd0 | ||
|
|
0ad84ff4f0 | ||
|
|
7d10ef8adf | ||
|
|
bc631e72de | ||
|
|
947ffefa9b | ||
|
|
db375cbee7 | ||
|
|
a46a4112cb | ||
|
|
966b2a5836 | ||
|
|
323b83b582 | ||
|
|
5d857de7a5 | ||
|
|
2facd8675c | ||
|
|
4870d49503 | ||
|
|
85858a160f | ||
|
|
0057925888 |
@@ -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, раздел «Релизы».
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -67,7 +72,8 @@ jobs:
|
|||||||
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
|
||||||
@@ -81,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`).
|
||||||
|
|
||||||
| Решение / сигнал | Читать |
|
| Решение / сигнал | Читать |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
|
|||||||
+129
@@ -1,5 +1,134 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* extend account list portfolio summary ([#138](https://git.personal.ksv741.ru/WealthLedger/backend/issues/138)) ([97fc41f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97fc41fa295a7a943a27171fa41cdaf286632956))
|
||||||
|
* **portfolio:** store and publish instrument sector and issuer (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/backend/issues/48)) ([#148](https://git.personal.ksv741.ru/WealthLedger/backend/issues/148)) ([17432df](https://git.personal.ksv741.ru/WealthLedger/backend/commit/17432dfee3d7d06841caea4419a062e2a4bfc82c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* count brokerage cash once and skip short-position return percent (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([f2843e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f2843e30bda5442158ec145cc63a5491506bb60f))
|
||||||
|
* reset portfolio value points inflated by double-counted cash (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([2325b63](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd))
|
||||||
|
* scope income and ledger sync to requested intervals (WealthLedger/backend[#57](https://git.personal.ksv741.ru/WealthLedger/backend/issues/57)) ([#147](https://git.personal.ksv741.ru/WealthLedger/backend/issues/147)) ([5e84e1e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5e84e1e5f1943b5af1992a3a946194b27fc1a1d5))
|
||||||
|
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* expose tbank-sync public api and guard its boundary (WealthLedger/backend[#118](https://git.personal.ksv741.ru/WealthLedger/backend/issues/118)) ([#143](https://git.personal.ksv741.ru/WealthLedger/backend/issues/143)) ([5295945](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5295945ab237a6e94aa64d7bb54bfd04554ca9f9))
|
||||||
|
* publish typed problem detail codes (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/backend/issues/117)) ([#144](https://git.personal.ksv741.ru/WealthLedger/backend/issues/144)) ([d37fa2c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d37fa2c9034e90e9d22d4022c45fc36c22dd2ab3))
|
||||||
|
* remove tbank-sync operations import cycle (WealthLedger/backend[#116](https://git.personal.ksv741.ru/WealthLedger/backend/issues/116)) ([#142](https://git.personal.ksv741.ru/WealthLedger/backend/issues/142)) ([22668da](https://git.personal.ksv741.ru/WealthLedger/backend/commit/22668da3fa0666bdd22737913379c99d4cf01993))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* cover portfolio publish rollback and claim fence (WealthLedger/backend[#105](https://git.personal.ksv741.ru/WealthLedger/backend/issues/105)) ([#146](https://git.personal.ksv741.ru/WealthLedger/backend/issues/146)) ([e350146](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e350146749293f2eaf8060f7626ff3c1cd8edf2c))
|
||||||
|
* replace operations sync delays with explicit barriers (WealthLedger/backend[#104](https://git.personal.ksv741.ru/WealthLedger/backend/issues/104)) ([#145](https://git.personal.ksv741.ru/WealthLedger/backend/issues/145)) ([99b137e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/99b137ed0b39ab73b6a28d1a494d2d98bf8fa380))
|
||||||
|
|
||||||
|
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
|
||||||
|
|
||||||
|
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
|
||||||
|
|
||||||
|
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
|
||||||
|
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
|
||||||
|
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
|
||||||
|
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
|
||||||
|
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
|
||||||
|
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
|
||||||
|
|
||||||
|
### Performance
|
||||||
|
|
||||||
|
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
|
||||||
|
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
|
||||||
|
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
|
||||||
|
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
|
||||||
|
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
|
||||||
|
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
|
||||||
|
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
|
||||||
|
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
|
||||||
|
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
|
||||||
|
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
|
||||||
|
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
|
||||||
|
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
|
||||||
|
|
||||||
|
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
|
||||||
|
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
|
||||||
|
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
|
||||||
|
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
|
||||||
|
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
|
||||||
|
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
|
||||||
|
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
|
||||||
|
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
|
||||||
|
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
|
||||||
|
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
|
||||||
|
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
|
||||||
|
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
|
||||||
|
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
|
||||||
|
|
||||||
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
|
||||||
|
|
||||||
### ⚠ BREAKING CHANGES
|
### ⚠ BREAKING CHANGES
|
||||||
|
|||||||
+6
-5
@@ -1,9 +1,9 @@
|
|||||||
FROM node:24.21.0-bookworm-slim AS base
|
FROM node:24.22.1-bookworm-slim AS base
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
FROM base AS dependencies
|
FROM base AS dependencies
|
||||||
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
|
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 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 python3 make g++ ca-certificates openssl && rm -rf /var/lib/apt/lists/*
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci
|
RUN npm ci
|
||||||
COPY . .
|
COPY . .
|
||||||
@@ -11,7 +11,7 @@ RUN npx --no-install prisma generate
|
|||||||
|
|
||||||
FROM dependencies AS development
|
FROM dependencies AS development
|
||||||
# Nest watch uses ps to stop the previous process tree before restarting.
|
# Nest watch uses ps to stop the previous process tree before restarting.
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends procps && rm -rf /var/lib/apt/lists/*
|
||||||
RUN mkdir /data && chown node:node /data && chown -R node:node /app
|
RUN mkdir /data && chown node:node /data && chown -R node:node /app
|
||||||
USER node
|
USER node
|
||||||
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
|
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
|
||||||
@@ -19,10 +19,11 @@ 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 openssl && rm -rf /var/lib/apt/lists/* \
|
||||||
&& mkdir /data && chown node:node /data
|
&& mkdir /data && chown node:node /data
|
||||||
ARG VERSION
|
ARG VERSION
|
||||||
ARG REVISION
|
ARG REVISION
|
||||||
|
|||||||
@@ -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,7 +61,7 @@ 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
|
||||||
@@ -80,11 +81,13 @@ 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.
|
||||||
|
|
||||||
@@ -215,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
|
||||||
@@ -248,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 |
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
@@ -295,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'],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -32,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
|
||||||
@@ -59,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
|
||||||
}
|
}
|
||||||
@@ -102,19 +91,11 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/investment-resource.mapper.ts": {
|
"src/modules/investments/investment-sync-plan.ts": {
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/investment-sync-plan.ts": {
|
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 4
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
"sonarjs/cognitive-complexity": {
|
||||||
"count": 2
|
"count": 2
|
||||||
@@ -149,30 +130,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": 1
|
|
||||||
},
|
|
||||||
"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": {
|
||||||
@@ -198,4 +163,4 @@
|
|||||||
"count": 3
|
"count": 3
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+22
-1
@@ -37,7 +37,7 @@ export default defineConfig(
|
|||||||
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||||
settings: {
|
settings: {
|
||||||
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
||||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
|
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
'curly': ['error', 'multi-line'],
|
'curly': ['error', 'multi-line'],
|
||||||
@@ -56,6 +56,7 @@ export default defineConfig(
|
|||||||
'after',
|
'after',
|
||||||
{ overrides: { '?': 'before', ':': 'before' } },
|
{ overrides: { '?': 'before', ':': 'before' } },
|
||||||
],
|
],
|
||||||
|
'import-x/no-cycle': 'error',
|
||||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -211,4 +212,24 @@ export default defineConfig(
|
|||||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
|
||||||
|
rules: {
|
||||||
|
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
|
||||||
|
'import-x/no-restricted-paths': ['error', {
|
||||||
|
zones: [{
|
||||||
|
target: './src',
|
||||||
|
from: './src/modules/investments/tbank-sync',
|
||||||
|
except: [
|
||||||
|
'./tbank-sync.api.ts',
|
||||||
|
'./tbank-sync.dto.ts',
|
||||||
|
'./tbank-sync.module.ts',
|
||||||
|
'./tbank-sync-repository.module.ts',
|
||||||
|
],
|
||||||
|
message: 'Use tbank-sync.api for cross-module access.',
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+1057
-1328
File diff suppressed because it is too large
Load Diff
+54
-45
@@ -1,20 +1,21 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.0.0",
|
"version": "2.4.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "v24.21.0"
|
"node": "v24.22.1"
|
||||||
},
|
},
|
||||||
"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",
|
||||||
@@ -31,93 +32,102 @@
|
|||||||
"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.5",
|
"@grpc/grpc-js": "1.14.6",
|
||||||
"@grpc/proto-loader": "0.8.1",
|
"@grpc/proto-loader": "0.8.1",
|
||||||
"@nestjs/common": "12.0.1",
|
"@nestjs/common": "12.2.0",
|
||||||
"@nestjs/config": "12.0.0",
|
"@nestjs/config": "12.0.1",
|
||||||
"@nestjs/core": "12.0.1",
|
"@nestjs/core": "12.2.0",
|
||||||
"@nestjs/jwt": "12.0.1",
|
"@nestjs/jwt": "12.0.3",
|
||||||
"@nestjs/passport": "12.0.0",
|
"@nestjs/passport": "12.0.0",
|
||||||
"@nestjs/platform-express": "12.0.1",
|
"@nestjs/platform-express": "12.2.0",
|
||||||
"@nestjs/swagger": "12.0.1",
|
"@nestjs/swagger": "12.0.4",
|
||||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
"@prisma/adapter-better-sqlite3": "7.10.1",
|
||||||
"@prisma/client": "7.10.0",
|
"@prisma/client": "7.10.1",
|
||||||
"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",
|
||||||
"cookie-parser": "1.4.7",
|
"cookie-parser": "1.4.7",
|
||||||
"dotenv": "17.4.2",
|
"dotenv": "17.4.2",
|
||||||
"lru-cache": "11.5.2",
|
"lru-cache": "11.5.3",
|
||||||
"nestjs-pino": "5.1.0",
|
"nestjs-pino": "5.3.1",
|
||||||
"passport": "0.7.0",
|
"passport": "0.7.0",
|
||||||
"passport-jwt": "4.0.1",
|
"passport-jwt": "4.0.1",
|
||||||
"pino-http": "11.0.0",
|
"pino-http": "11.0.0",
|
||||||
"prisma": "7.10.0",
|
"prisma": "7.10.1",
|
||||||
"reflect-metadata": "0.2.2",
|
"reflect-metadata": "0.2.2",
|
||||||
"rxjs": "7.8.2"
|
"rxjs": "7.8.2"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@commitlint/cli": "21.2.2",
|
"@commitlint/cli": "21.2.4",
|
||||||
"@commitlint/config-conventional": "21.2.2",
|
"@commitlint/config-conventional": "21.2.4",
|
||||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
|
"@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
|
||||||
"@eslint/js": "10.0.1",
|
"@eslint/js": "10.0.1",
|
||||||
"@nestjs/cli": "12.0.0",
|
"@nestjs/cli": "12.0.10",
|
||||||
"@nestjs/schematics": "12.0.1",
|
"@nestjs/schematics": "12.0.10",
|
||||||
"@nestjs/testing": "12.0.1",
|
"@nestjs/testing": "12.2.0",
|
||||||
"@release-it/conventional-changelog": "12.0.0",
|
"@release-it/conventional-changelog": "12.0.2",
|
||||||
"@stylistic/eslint-plugin": "5.10.0",
|
"@stylistic/eslint-plugin": "5.10.0",
|
||||||
"@types/bcrypt": "6.0.0",
|
"@types/bcrypt": "6.0.0",
|
||||||
"@types/cookie-parser": "1.4.10",
|
"@types/cookie-parser": "1.4.10",
|
||||||
"@types/express": "5.0.6",
|
"@types/express": "5.0.6",
|
||||||
"@types/jest": "30.0.0",
|
"@types/jest": "30.0.0",
|
||||||
"@types/node": "24.13.4",
|
"@types/node": "24.19.2",
|
||||||
"@types/passport-jwt": "4.0.1",
|
"@types/passport-jwt": "4.0.1",
|
||||||
"@types/supertest": "7.2.1",
|
"@types/supertest": "7.2.1",
|
||||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||||
"eslint": "10.11.0",
|
"eslint": "10.13.0",
|
||||||
"eslint-import-resolver-typescript": "4.4.5",
|
"eslint-import-resolver-typescript": "4.4.5",
|
||||||
"eslint-plugin-import-x": "4.17.1",
|
"eslint-plugin-import-x": "4.17.1",
|
||||||
"eslint-plugin-jest": "29.16.6",
|
"eslint-plugin-jest": "29.17.0",
|
||||||
"eslint-plugin-n": "18.4.0",
|
"eslint-plugin-n": "18.5.0",
|
||||||
"eslint-plugin-perfectionist": "5.12.1",
|
"eslint-plugin-perfectionist": "5.13.0",
|
||||||
"eslint-plugin-regexp": "3.3.1",
|
"eslint-plugin-regexp": "3.3.1",
|
||||||
"eslint-plugin-security": "4.1.0",
|
"eslint-plugin-security": "4.2.0",
|
||||||
"eslint-plugin-sonarjs": "4.2.1",
|
"eslint-plugin-sonarjs": "4.2.2",
|
||||||
"globals": "17.12.0",
|
"globals": "17.13.0",
|
||||||
"jest": "30.5.1",
|
"jest": "30.5.2",
|
||||||
"release-it": "21.0.2",
|
"pino-pretty": "13.2.0",
|
||||||
"source-map-support": "0.5.21",
|
"release-it": "21.1.1",
|
||||||
"supertest": "7.2.2",
|
"supertest": "7.3.1",
|
||||||
"ts-jest": "29.4.12",
|
"ts-jest": "29.4.15",
|
||||||
"ts-loader": "9.6.2",
|
"ts-loader": "9.6.2",
|
||||||
"ts-node": "10.9.2",
|
"ts-node": "10.9.2",
|
||||||
"tsconfig-paths": "4.2.0",
|
"tsconfig-paths": "4.2.0",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.70.1"
|
"typescript-eslint": "8.72.0"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"proxy-addr": "2.0.8",
|
"proxy-addr": "2.0.8",
|
||||||
|
"express": {
|
||||||
|
"body-parser": "2.4.0"
|
||||||
|
},
|
||||||
"@nestjs/platform-express": {
|
"@nestjs/platform-express": {
|
||||||
"multer": "2.4.0"
|
"multer": "2.5.0"
|
||||||
|
},
|
||||||
|
"ajv": {
|
||||||
|
"fast-uri": "3.1.9"
|
||||||
},
|
},
|
||||||
"@prisma/config@7.10.0": {
|
"@prisma/config@7.10.0": {
|
||||||
"deepmerge-ts": "8.0.2"
|
"deepmerge-ts": "8.0.2"
|
||||||
},
|
},
|
||||||
"release-it": {
|
"release-it": {
|
||||||
"basic-ftp": "6.2.1",
|
"basic-ftp": "6.2.3",
|
||||||
"undici": "7.29.1"
|
"undici": "7.30.1"
|
||||||
|
},
|
||||||
|
"socks": {
|
||||||
|
"ip-address": "10.7.4"
|
||||||
},
|
},
|
||||||
"prisma": {
|
"prisma": {
|
||||||
"mysql2": "3.24.4"
|
"mysql2": "3.24.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
@@ -144,7 +154,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");
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Points written before WealthLedger/backend#140 counted cash twice (cash balance
|
||||||
|
-- plus the mirroring CURRENCY position). Historical compositions and FX rates are
|
||||||
|
-- not stored, so the points cannot be recomputed exactly; history restarts here.
|
||||||
|
DELETE FROM "TbankPortfolioValuePoint";
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
CREATE TABLE "TbankInstrumentClassification" (
|
||||||
|
"provider" TEXT NOT NULL,
|
||||||
|
"instrumentKey" TEXT NOT NULL,
|
||||||
|
"sector" TEXT,
|
||||||
|
"sectorFetchedAt" DATETIME,
|
||||||
|
"assetUid" TEXT,
|
||||||
|
"brandUid" TEXT,
|
||||||
|
"brandName" TEXT,
|
||||||
|
"brandFetchedAt" DATETIME,
|
||||||
|
PRIMARY KEY ("provider","instrumentKey")
|
||||||
|
);
|
||||||
@@ -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
|
||||||
@@ -157,6 +186,20 @@ model TbankInstrumentLookup {
|
|||||||
@@id([provider, identifierType, identifier])
|
@@id([provider, identifierType, identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public per-instrument classification; no holdings or credentials.
|
||||||
|
model TbankInstrumentClassification {
|
||||||
|
provider String
|
||||||
|
instrumentKey String
|
||||||
|
sector String?
|
||||||
|
sectorFetchedAt DateTime?
|
||||||
|
assetUid String?
|
||||||
|
brandUid String?
|
||||||
|
brandName String?
|
||||||
|
brandFetchedAt DateTime?
|
||||||
|
|
||||||
|
@@id([provider, instrumentKey])
|
||||||
|
}
|
||||||
|
|
||||||
// Source records contain no holdings, personal estimates or credentials errors.
|
// Source records contain no holdings, personal estimates or credentials errors.
|
||||||
model TbankInstrumentSource {
|
model TbankInstrumentSource {
|
||||||
provider String
|
provider String
|
||||||
|
|||||||
+30
-1
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:recommended",
|
"config:recommended",
|
||||||
":semanticCommitTypeAll(chore)"
|
":semanticCommitTypeAll(chore)",
|
||||||
|
"security:minimumReleaseAgeNpm"
|
||||||
],
|
],
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
"labels": [
|
"labels": [
|
||||||
@@ -10,6 +11,13 @@
|
|||||||
],
|
],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"rangeStrategy": "pin",
|
"rangeStrategy": "pin",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": [
|
||||||
|
"before 4am on the first day of the month"
|
||||||
|
]
|
||||||
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
@@ -18,6 +26,27 @@
|
|||||||
],
|
],
|
||||||
"groupName": "non-major dependencies"
|
"groupName": "non-major dependencies"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"github-actions"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major github actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"docker-compose",
|
||||||
|
"dockerfile"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major container images"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
"major"
|
"major"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
});
|
||||||
|
|||||||
@@ -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,24 +1,29 @@
|
|||||||
import { ArgumentsHost } from '@nestjs/common';
|
import { ArgumentsHost, BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||||
import { HttpExceptionFilter } from './http-exception.filter';
|
import { HttpExceptionFilter } from './http-exception.filter';
|
||||||
|
|
||||||
describe('HttpExceptionFilter', () => {
|
describe('HttpExceptionFilter', () => {
|
||||||
it('does not expose internal error details', () => {
|
const setup = () => {
|
||||||
const status = jest.fn().mockReturnThis();
|
const status = jest.fn().mockReturnThis();
|
||||||
const type = jest.fn().mockReturnThis();
|
const type = jest.fn().mockReturnThis();
|
||||||
const json = jest.fn();
|
const json = jest.fn();
|
||||||
const locals: Record<string, unknown> = {};
|
const locals: Record<string, unknown> = {};
|
||||||
const filter = new HttpExceptionFilter();
|
const logger = { event: jest.fn() };
|
||||||
|
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
|
||||||
const host = {
|
const host = {
|
||||||
switchToHttp: () => ({
|
switchToHttp: () => ({
|
||||||
getResponse: () => ({ status, type, json, locals }),
|
getResponse: () => ({ status, type, json, locals }),
|
||||||
}),
|
}),
|
||||||
} as unknown as ArgumentsHost;
|
} as unknown as ArgumentsHost;
|
||||||
|
return { filter, host, status, type, json, locals, logger };
|
||||||
|
};
|
||||||
|
|
||||||
filter.catch(
|
it('logs unhandled errors with name and stack frames but never the message', () => {
|
||||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
const { filter, host, status, type, json, locals, logger } = setup();
|
||||||
host,
|
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
|
||||||
);
|
|
||||||
|
filter.catch(error, host);
|
||||||
|
|
||||||
expect(status).toHaveBeenCalledWith(500);
|
expect(status).toHaveBeenCalledWith(500);
|
||||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||||
@@ -27,6 +32,29 @@ describe('HttpExceptionFilter', () => {
|
|||||||
code: 'INTERNAL_SERVER_ERROR',
|
code: 'INTERNAL_SERVER_ERROR',
|
||||||
});
|
});
|
||||||
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||||
expect(locals.errorStack).toBeUndefined();
|
expect(logger.event).toHaveBeenCalledTimes(1);
|
||||||
|
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
|
||||||
|
expect(level).toBe('error');
|
||||||
|
expect(event).toBe('http.request.unhandled');
|
||||||
|
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
|
||||||
|
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
|
||||||
|
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs field pointers without values for validation errors', () => {
|
||||||
|
const { filter, host, status, logger, locals } = setup();
|
||||||
|
const exception = new BadRequestException({
|
||||||
|
message: [
|
||||||
|
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
|
||||||
|
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
filter.catch(exception, host);
|
||||||
|
|
||||||
|
expect(status).toHaveBeenCalledWith(400);
|
||||||
|
expect(locals.invalidFields).toBe('#/amount');
|
||||||
|
expect(logger.event).not.toHaveBeenCalled();
|
||||||
|
expect(JSON.stringify(locals)).not.toContain('-42');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,9 +10,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
import { HttpErrorCode } from '../error-code';
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
|
constructor(private readonly logger: StructuredLogger) {}
|
||||||
|
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
const context = host.switchToHttp();
|
const context = host.switchToHttp();
|
||||||
const response = context.getResponse<Response>();
|
const response = context.getResponse<Response>();
|
||||||
@@ -31,6 +34,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
: this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
response.locals ??= {};
|
response.locals ??= {};
|
||||||
response.locals.publicErrorCode = code;
|
response.locals.publicErrorCode = code;
|
||||||
|
if (isValidation) {
|
||||||
|
response.locals.invalidFields = [
|
||||||
|
...new Set(
|
||||||
|
this.validationErrors(
|
||||||
|
(payload as { message: ValidationError[] }).message,
|
||||||
|
).map((error) => error.pointer),
|
||||||
|
),
|
||||||
|
].join(',');
|
||||||
|
}
|
||||||
|
if (!(exception instanceof HttpException)) {
|
||||||
|
this.logUnhandled(exception, code);
|
||||||
|
}
|
||||||
response
|
response
|
||||||
.status(status)
|
.status(status)
|
||||||
.type('application/problem+json')
|
.type('application/problem+json')
|
||||||
@@ -47,6 +62,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
|
||||||
|
private logUnhandled(exception: unknown, code: string): void {
|
||||||
|
const error = exception instanceof Error ? exception : undefined;
|
||||||
|
const frames = (error?.stack ?? '')
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.startsWith(' at '))
|
||||||
|
.join('\n');
|
||||||
|
this.logger.event('error', 'http.request.unhandled', {
|
||||||
|
code,
|
||||||
|
errorName: error?.name ?? typeof exception,
|
||||||
|
errorStack: frames,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
private codeFor(status: number, payload: unknown): string {
|
private codeFor(status: number, payload: unknown): string {
|
||||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||||
const code = (payload as { code?: unknown }).code;
|
const code = (payload as { code?: unknown }).code;
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ function completion(request: Request, response: Response, value: { responseTime:
|
|||||||
status: response.statusCode,
|
status: response.statusCode,
|
||||||
durationMs: Math.round(value.responseTime),
|
durationMs: Math.round(value.responseTime),
|
||||||
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||||
|
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,6 +32,10 @@ export function loggingOptions(config: ConfigService<Environment, true>): Option
|
|||||||
return {
|
return {
|
||||||
level: config.getOrThrow('LOG_LEVEL'),
|
level: config.getOrThrow('LOG_LEVEL'),
|
||||||
messageKey: 'message',
|
messageKey: 'message',
|
||||||
|
// ponytail: development-only pretty output; production never loads pino-pretty.
|
||||||
|
...(process.env['NODE_ENV'] === 'development'
|
||||||
|
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
|
||||||
|
: {}),
|
||||||
genReqId: (_request, response) => {
|
genReqId: (_request, response) => {
|
||||||
const id = randomUUID();
|
const id = randomUUID();
|
||||||
response.setHeader('X-Request-ID', id);
|
response.setHeader('X-Request-ID', id);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ describe('StructuredLogger', () => {
|
|||||||
const logger = new StructuredLogger(pino as never);
|
const logger = new StructuredLogger(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',
|
||||||
@@ -21,6 +22,7 @@ describe('StructuredLogger', () => {
|
|||||||
|
|
||||||
expect(pino.info).toHaveBeenCalledWith(
|
expect(pino.info).toHaveBeenCalledWith(
|
||||||
{
|
{
|
||||||
|
asOf: '2026-10-10T12:00:00.000Z',
|
||||||
userId: 'user',
|
userId: 'user',
|
||||||
brokerageAccountId: 'account',
|
brokerageAccountId: 'account',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,21 +5,30 @@ 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',
|
||||||
'durationMs',
|
'durationMs',
|
||||||
'environment',
|
'environment',
|
||||||
|
'errorName',
|
||||||
|
'errorStack',
|
||||||
'found',
|
'found',
|
||||||
'grpcCode',
|
'grpcCode',
|
||||||
|
'headersReceived',
|
||||||
|
'invalidFields',
|
||||||
'method',
|
'method',
|
||||||
|
'networkMs',
|
||||||
'operation',
|
'operation',
|
||||||
|
'queueWaitMs',
|
||||||
'requestId',
|
'requestId',
|
||||||
'route',
|
'route',
|
||||||
'service',
|
'service',
|
||||||
'status',
|
'status',
|
||||||
|
'trackingId',
|
||||||
'userId',
|
'userId',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||||
|
|
||||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||||
const FIRST_PAGE_OFFSET = 0;
|
const FIRST_PAGE_OFFSET = 0;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache';
|
|||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||||
|
|
||||||
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||||
|
|
||||||
const EXPECTED_REGISTRY_TABLES = 1;
|
const EXPECTED_REGISTRY_TABLES = 1;
|
||||||
const RATING_COLUMN = 2;
|
const RATING_COLUMN = 2;
|
||||||
|
|||||||
@@ -7,12 +7,12 @@ import type {
|
|||||||
TbankInstrument as MetadataRow,
|
TbankInstrument as MetadataRow,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../../generated/prisma/client';
|
} from '../../../generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -120,11 +120,8 @@ export class InstrumentMetadataRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankConnection.updateMany({
|
const valid = await lockConnectionGeneration(tx, attemptOwner);
|
||||||
where: { ...attemptOwner, invalidToken: false },
|
if (!valid) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!valid.count) return null;
|
|
||||||
const scope = {
|
const scope = {
|
||||||
...attemptOwner,
|
...attemptOwner,
|
||||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
@@ -132,11 +129,7 @@ export class InstrumentMetadataRepository {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -172,15 +165,11 @@ export class InstrumentMetadataRepository {
|
|||||||
|
|
||||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const aliases = new Map(
|
const aliases = new Map(
|
||||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||||
);
|
);
|
||||||
@@ -269,18 +258,17 @@ export class InstrumentMetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await claimSyncWork(
|
||||||
where: {
|
this.prisma,
|
||||||
...attempt,
|
attempt,
|
||||||
state: 'running',
|
{
|
||||||
...(success ? { connection: { invalidToken: false } } : {}),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
{ withoutConnectionFence: !success },
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -320,11 +308,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(
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||||
@@ -25,6 +26,22 @@ export type TbankSyncAttempt = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Join rule shared by sync reservations: the same process joins its own running attempt
|
||||||
|
// only while that attempt is still active in memory. Set and Map both satisfy `has`.
|
||||||
|
export function isJoinableRunningAttempt<
|
||||||
|
T extends { attemptId: string; processRunId: string; state: string },
|
||||||
|
>(
|
||||||
|
previous: null | T,
|
||||||
|
processRunId: string,
|
||||||
|
active: { has(attemptId: string): boolean },
|
||||||
|
): previous is T {
|
||||||
|
return (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankAccessService {
|
export class TbankAccessService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,11 +57,8 @@ export class TbankAccessService {
|
|||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, { userId, revision });
|
||||||
where: { userId, revision, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
@@ -62,11 +76,7 @@ export class TbankAccessService {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, processRunId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -98,15 +108,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(this.prisma, attempt, {
|
||||||
where: {
|
state: 'failed',
|
||||||
...attempt,
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||||
@@ -121,6 +127,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[],
|
||||||
@@ -134,15 +157,8 @@ export class TbankAccessService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// Acquire the write fence before reading membership. Replacement and
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
// attempt supersession cannot interleave with the publication below.
|
// attempt supersession cannot interleave with the publication below.
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||||
where: {
|
if (!claimed) return false;
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (!claimed.count) return false;
|
|
||||||
const { userId, revision } = attempt;
|
const { userId, revision } = attempt;
|
||||||
const previous = await tx.tbankAccount.findMany({
|
const previous = await tx.tbankAccount.findMany({
|
||||||
where: { userId, revision },
|
where: { userId, revision },
|
||||||
@@ -192,6 +208,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 },
|
||||||
@@ -204,11 +231,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: { ...attempt, state: 'running' },
|
state: 'failed',
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
errorCode: TbankErrorCode.InvalidToken,
|
||||||
});
|
}, { withoutConnectionFence: true });
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
await tx.tbankConnection.update({
|
await tx.tbankConnection.update({
|
||||||
where: {
|
where: {
|
||||||
userId_revision: {
|
userId_revision: {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import type { Prisma } from '../../../generated/prisma/client';
|
||||||
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
|
|
||||||
|
const NO_UPDATED_ROWS = 0;
|
||||||
|
|
||||||
|
// Shared owners for the tbank-sync DB claim/lock protocol.
|
||||||
|
// Both helpers are CAS fences: they run inside the caller's transaction and
|
||||||
|
// keep the exact where/data shapes they replaced. Callers keep deciding
|
||||||
|
// success from the claim count exactly as before.
|
||||||
|
|
||||||
|
// Claims the active TbankSyncWork row: only the still-running attempt of a
|
||||||
|
// valid connection may transition. `data` is the state transition to apply.
|
||||||
|
export type ClaimOptions = {
|
||||||
|
// Overrides the default 'running' fence (e.g. an initiator row also
|
||||||
|
// accepts 'complete').
|
||||||
|
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
|
||||||
|
// true drops the `connection: { invalidToken: false }` guard for fences
|
||||||
|
// that must also fire on an invalidated connection.
|
||||||
|
withoutConnectionFence: boolean | undefined;
|
||||||
|
};
|
||||||
|
export async function claimSyncWork(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
|
||||||
|
options: Partial<ClaimOptions> = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: options.state ?? 'running',
|
||||||
|
...(options.withoutConnectionFence
|
||||||
|
? {}
|
||||||
|
: { connection: { invalidToken: false } }),
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks the connection generation before reserving or publishing work: the
|
||||||
|
// row must still be the current revision with a valid token. `extra`
|
||||||
|
// (e.g. account membership) is merged into the where clause.
|
||||||
|
export async function lockConnectionGeneration(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
owner: { revision: string; userId: string },
|
||||||
|
extra: Prisma.TbankConnectionWhereInput = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, ...extra, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
return result.count > NO_UPDATED_ROWS;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
|
|||||||
export { metadataKey } from './instrument-metadata.service';
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
|||||||
@@ -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', () => {
|
||||||
@@ -171,6 +236,16 @@ describe('TbankClient metadata retries', () => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['bondBy', (client: TbankClient, optIn: boolean) => client.getBond('token', 'uid', undefined, optIn)],
|
||||||
|
['etfBy', (client: TbankClient, optIn: boolean) => client.getEtf('token', 'uid', undefined, optIn)],
|
||||||
|
['getAssetBy', (client: TbankClient, optIn: boolean) => optIn ? client.getAssetBrand('token', 'uid') : client.getAssetDescription('token', 'uid')],
|
||||||
|
])('%s reports NOT_FOUND only for classification lookups', async (_method, invoke) => {
|
||||||
|
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||||
|
await expect(invoke(client, false)).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
await expect(invoke(client, true)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
status.UNAVAILABLE,
|
status.UNAVAILABLE,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
@@ -282,3 +357,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';
|
||||||
@@ -59,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 = {
|
||||||
@@ -325,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,
|
||||||
@@ -337,18 +375,29 @@ export class TbankClient {
|
|||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getBond(token: string, uid: string): Promise<null | TbankBond> {
|
getBond(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankBond> {
|
||||||
return this.call<{ instrument?: TbankBond }>(
|
return this.call<{ instrument?: TbankBond }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
'bondBy',
|
'bondBy',
|
||||||
{
|
{
|
||||||
idType: InstrumentIdType.Uid,
|
idType,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
|
notFound,
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEtf(
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
idType = InstrumentIdType.Uid,
|
||||||
|
notFound = false,
|
||||||
|
): Promise<null | Pick<TbankShare, 'assetUid' | 'figi' | 'sector' | 'uid'>> {
|
||||||
|
return this.call<{ instrument?: TbankShare }>(token, this.sparseInstruments, 'etfBy', { idType, id }, notFound)
|
||||||
|
.then((response) => response.instrument ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
getBondCoupons(
|
getBondCoupons(
|
||||||
token: string,
|
token: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -367,18 +416,25 @@ export class TbankClient {
|
|||||||
).then((response) => response.events ?? []);
|
).then((response) => response.events ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
getShare(token: string, uid: string): Promise<null | TbankShare> {
|
getShare(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankShare> {
|
||||||
return this.call<{ instrument?: TbankShare }>(
|
return this.call<{ instrument?: TbankShare }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
'shareBy',
|
'shareBy',
|
||||||
{
|
{
|
||||||
idType: InstrumentIdType.Uid,
|
idType,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
|
notFound,
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAssetBrand(token: string, assetUid: string): Promise<null | { assetUid?: string; brand?: { name?: string; uid?: string } }> {
|
||||||
|
return this.call<{ asset?: { brand?: { name?: string; uid?: string }; uid?: string } }>(
|
||||||
|
token, this.instruments, 'getAssetBy', { id: assetUid }, true,
|
||||||
|
).then((response) => response.asset ? { assetUid: response.asset.uid, brand: response.asset.brand } : null);
|
||||||
|
}
|
||||||
|
|
||||||
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
||||||
return this.call<{ asset?: { description?: string } }>(
|
return this.call<{ asset?: { description?: string } }>(
|
||||||
token,
|
token,
|
||||||
@@ -476,20 +532,29 @@ export class TbankClient {
|
|||||||
client: TbankClientService,
|
client: TbankClientService,
|
||||||
method: string,
|
method: string,
|
||||||
request: object,
|
request: object,
|
||||||
|
notFound = false,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; 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),
|
||||||
});
|
});
|
||||||
@@ -507,6 +572,7 @@ export class TbankClient {
|
|||||||
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||||
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),
|
||||||
@@ -516,6 +582,7 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
this.logger.event('error', 'tbank.rpc.failed', {
|
this.logger.event('error', 'tbank.rpc.failed', {
|
||||||
operation: method,
|
operation: method,
|
||||||
|
...diagnostics,
|
||||||
...failureContext,
|
...failureContext,
|
||||||
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||||
durationMs: Math.round(performance.now() - startedAt),
|
durationMs: Math.round(performance.now() - startedAt),
|
||||||
@@ -528,7 +595,7 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
(method === 'shareBy' || metadataCall)
|
(notFound || method === 'shareBy' || metadataCall)
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
@@ -543,17 +610,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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -37,7 +37,7 @@ export function configureApp(app: INestApplication): void {
|
|||||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.useGlobalFilters(new HttpExceptionFilter());
|
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
|
||||||
app.enableShutdownHooks();
|
app.enableShutdownHooks();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
|||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
const REFRESH_TTL_MS = 604_800_000;
|
export const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
const ACCESS_TTL = '15m';
|
const ACCESS_TTL = '15m';
|
||||||
const REFRESH_TTL = '7d';
|
const REFRESH_TTL = '7d';
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ 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, REFRESH_TTL_MS } from '../application/auth.service';
|
||||||
import { AccessPayload, AuthenticationResult } 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(
|
||||||
@@ -134,6 +134,7 @@ export class AuthController {
|
|||||||
sameSite: 'lax' as const,
|
sameSite: 'lax' as const,
|
||||||
secure: process.env['NODE_ENV'] !== 'development',
|
secure: process.env['NODE_ENV'] !== 'development',
|
||||||
path: COOKIE_PATH,
|
path: COOKIE_PATH,
|
||||||
|
maxAge: REFRESH_TTL_MS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
|
getSchemaPath,
|
||||||
IntersectionType,
|
IntersectionType,
|
||||||
OmitType,
|
OmitType,
|
||||||
PartialType,
|
PartialType,
|
||||||
@@ -153,6 +154,49 @@ export class CreateBankOperationDto {
|
|||||||
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
|
||||||
|
// invalid combinations keep the existing BANK_INVALID_INPUT response.
|
||||||
|
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
|
||||||
|
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
|
||||||
|
] as const) {}
|
||||||
|
|
||||||
|
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBankOperationSchema = {
|
||||||
|
oneOf: [
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
].map((model) => ({ $ref: getSchemaPath(model) })),
|
||||||
|
discriminator: {
|
||||||
|
propertyName: 'kind',
|
||||||
|
mapping: {
|
||||||
|
expense: getSchemaPath(CreateExpenseOperationDto),
|
||||||
|
topup: getSchemaPath(CreateTopupOperationDto),
|
||||||
|
transfer: getSchemaPath(CreateTransferOperationDto),
|
||||||
|
refund: getSchemaPath(CreateRefundOperationDto),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
export class UpdateBankOperationDto extends PartialType(
|
export class UpdateBankOperationDto extends PartialType(
|
||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiExtraModels,
|
||||||
ApiNoContentResponse,
|
ApiNoContentResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -46,6 +48,11 @@ import {
|
|||||||
CategoryNameDto,
|
CategoryNameDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
|
createBankOperationSchema,
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
ExpenseCategoryDto,
|
ExpenseCategoryDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
@@ -73,6 +80,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiTags('bank-cards')
|
@ApiTags('bank-cards')
|
||||||
|
@ApiExtraModels(
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiProblemResponses()
|
@ApiProblemResponses()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -131,6 +144,7 @@ export class BankCardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
|
@ApiBody({ schema: createBankOperationSchema })
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
createOperation(
|
createOperation(
|
||||||
@Req() req: AuthRequest,
|
@Req() req: AuthRequest,
|
||||||
|
|||||||
@@ -2,11 +2,39 @@ 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 { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||||
|
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||||
|
|
||||||
|
export class UnrealizedReturnDto {
|
||||||
|
@ApiProperty()
|
||||||
|
declare currency: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '12.345678901' })
|
||||||
|
declare absolute: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: '100.00' })
|
||||||
|
declare costBasis: string;
|
||||||
|
|
||||||
|
@ApiProperty({ type: String, nullable: true, example: '12.34' })
|
||||||
|
declare percent: null | string;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
declare partial: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
declare totalsByCurrency: MoneyDto[];
|
declare totalsByCurrency: MoneyDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||||
|
declare cashBalances: MoneyDto[];
|
||||||
|
|
||||||
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
|
declare structure: PortfolioStructureDto;
|
||||||
|
|
||||||
|
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
||||||
|
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
minimum: 0,
|
minimum: 0,
|
||||||
@@ -20,6 +48,20 @@ export class PortfolioSummaryDto {
|
|||||||
description: 'Number of non-zero bond positions, not the number of units.',
|
description: 'Number of non-zero bond positions, not the number of units.',
|
||||||
})
|
})
|
||||||
declare bondPositionCount: number;
|
declare bondPositionCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
description: 'Number of non-zero fund positions, not the number of units.',
|
||||||
|
})
|
||||||
|
declare fundPositionCount: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
type: 'integer',
|
||||||
|
minimum: 0,
|
||||||
|
description: 'Number of all non-zero positions, not the number of units.',
|
||||||
|
})
|
||||||
|
declare totalPositionCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class BrokerageAccountDto {
|
export class BrokerageAccountDto {
|
||||||
@@ -55,4 +97,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,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,19 +19,21 @@ 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 () => {
|
||||||
|
const structure = { currencies: [], unvaluedCategories: [] };
|
||||||
portfolio.saved.mockResolvedValue({
|
portfolio.saved.mockResolvedValue({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [
|
accounts: [
|
||||||
@@ -42,10 +44,12 @@ describe('saved brokerage account projection', () => {
|
|||||||
openedOn: null,
|
openedOn: null,
|
||||||
meta,
|
meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
|
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||||
|
structure,
|
||||||
totalsByCurrency: [
|
totalsByCurrency: [
|
||||||
{ currency: 'RUB', value: '100000000.000000001' },
|
{ currency: 'RUB', value: '100000000.000000001' },
|
||||||
],
|
],
|
||||||
positions: [{ type: 'STOCK' }, { type: 'BOND' }],
|
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -58,13 +62,122 @@ 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({
|
||||||
|
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||||
|
structure,
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
|
fundPositionCount: 0,
|
||||||
|
totalPositionCount: 2,
|
||||||
|
unrealizedReturnByCurrency: [],
|
||||||
});
|
});
|
||||||
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('returns an empty summary from an empty saved portfolio', async () => {
|
||||||
|
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
|
||||||
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
|
membership: meta,
|
||||||
|
accounts: [{
|
||||||
|
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
|
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
||||||
|
cashBalances: [], structure, totalsByCurrency: [],
|
||||||
|
stockPositionCount: 0, bondPositionCount: 0,
|
||||||
|
fundPositionCount: 0, totalPositionCount: 0,
|
||||||
|
unrealizedReturnByCurrency: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
|
||||||
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
|
membership: meta,
|
||||||
|
accounts: [{
|
||||||
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
|
snapshot: {
|
||||||
|
cashBalances: [], structure: {}, totalsByCurrency: [
|
||||||
|
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
||||||
|
],
|
||||||
|
positions: [
|
||||||
|
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
||||||
|
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
|
||||||
|
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
|
||||||
|
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
||||||
|
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
|
||||||
|
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||||
|
stockPositionCount: 2, bondPositionCount: 2,
|
||||||
|
fundPositionCount: 1, totalPositionCount: 6,
|
||||||
|
unrealizedReturnByCurrency: [
|
||||||
|
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||||
|
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
|
||||||
|
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits return percent for a currency with short positions', async () => {
|
||||||
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
|
membership: meta,
|
||||||
|
accounts: [{
|
||||||
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
|
snapshot: {
|
||||||
|
cashBalances: [], structure: {}, totalsByCurrency: [],
|
||||||
|
positions: [
|
||||||
|
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
||||||
|
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
|
||||||
|
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
||||||
|
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,94 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decimalFromString,
|
||||||
|
divide,
|
||||||
|
formatDecimal,
|
||||||
|
isZero,
|
||||||
|
multiply,
|
||||||
|
} from '../../../common/decimal';
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
||||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||||
|
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
||||||
|
import {
|
||||||
|
BrokerageAccountDto,
|
||||||
|
BrokerageAccountsResourceDto,
|
||||||
|
UnrealizedReturnDto,
|
||||||
|
} from './brokerage-account.dto';
|
||||||
|
|
||||||
|
const PERCENT_DECIMAL_PLACES = 2;
|
||||||
|
const ZERO_QUANTITY = 0n;
|
||||||
|
|
||||||
|
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
||||||
|
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
||||||
|
const investments = positions.filter((position) => position.type !== 'CURRENCY');
|
||||||
|
const absolute = sumMoneyByCurrency(
|
||||||
|
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
|
||||||
|
);
|
||||||
|
const costs = sumMoneyByCurrency(
|
||||||
|
investments.flatMap((position) =>
|
||||||
|
position.absoluteReturn && position.averagePurchasePrice
|
||||||
|
? [{
|
||||||
|
currency: position.absoluteReturn.currency,
|
||||||
|
value: formatDecimal(multiply(
|
||||||
|
decimalFromString(position.averagePurchasePrice.value),
|
||||||
|
decimalFromString(position.quantity),
|
||||||
|
)),
|
||||||
|
}]
|
||||||
|
: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const partial = new Set(
|
||||||
|
investments.flatMap((position) => {
|
||||||
|
const money = position.absoluteReturn
|
||||||
|
? null
|
||||||
|
: position.averagePurchasePrice ?? position.currentValue;
|
||||||
|
return money ? [money.currency] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// A short position has negative cost basis, so a percent would flip sign or net out.
|
||||||
|
const withShorts = new Set(
|
||||||
|
investments.flatMap((position) => {
|
||||||
|
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
||||||
|
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
|
||||||
|
return [...new Set([...currencies, ...partial])]
|
||||||
|
.sort((left, right) => left.localeCompare(right))
|
||||||
|
.map((currency) => {
|
||||||
|
const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0';
|
||||||
|
const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0';
|
||||||
|
const cost = decimalFromString(costBasis);
|
||||||
|
return {
|
||||||
|
currency,
|
||||||
|
absolute: absoluteReturn,
|
||||||
|
costBasis,
|
||||||
|
percent: isZero(cost) || withShorts.has(currency)
|
||||||
|
? null
|
||||||
|
: formatDecimal(divide(
|
||||||
|
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
||||||
|
cost,
|
||||||
|
PERCENT_DECIMAL_PLACES,
|
||||||
|
)),
|
||||||
|
partial: partial.has(currency),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
@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,
|
||||||
@@ -22,15 +98,25 @@ export class BrokerageAccountsService {
|
|||||||
portfolioSummary: account.snapshot
|
portfolioSummary: account.snapshot
|
||||||
? {
|
? {
|
||||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||||
|
cashBalances: account.snapshot.cashBalances,
|
||||||
|
structure: account.snapshot.structure,
|
||||||
|
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
||||||
stockPositionCount: account.snapshot.positions.filter(
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'STOCK',
|
(position) => position.type === 'STOCK',
|
||||||
).length,
|
).length,
|
||||||
bondPositionCount: account.snapshot.positions.filter(
|
bondPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'BOND',
|
(position) => position.type === 'BOND',
|
||||||
).length,
|
).length,
|
||||||
|
fundPositionCount: account.snapshot.positions.filter(
|
||||||
|
(position) => position.type === 'FUND',
|
||||||
|
).length,
|
||||||
|
// The snapshot mapper already drops zero-quantity positions.
|
||||||
|
totalPositionCount: account.snapshot.positions.length,
|
||||||
}
|
}
|
||||||
: 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 +135,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),
|
||||||
}));
|
}));
|
||||||
@@ -247,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,38 +161,6 @@ 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,
|
||||||
@@ -290,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', () => {
|
||||||
|
|||||||
@@ -8,51 +8,34 @@ import {
|
|||||||
utcDate,
|
utcDate,
|
||||||
} from '../investment-calendar';
|
} from '../investment-calendar';
|
||||||
|
|
||||||
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||||
|
|
||||||
const MONDAY_WEEKDAY_INDEX = 1;
|
const MONDAY_WEEKDAY_INDEX = 1;
|
||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
const INCLUSIVE_DAY_OFFSET = 1;
|
||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
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 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import { timestamp } from './instrument-market';
|
|||||||
|
|
||||||
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';
|
||||||
@@ -106,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();
|
||||||
@@ -114,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));
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import {
|
|||||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
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;
|
||||||
@@ -58,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(
|
||||||
@@ -109,6 +110,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
@@ -122,6 +124,7 @@ export class CatalogEnrichmentService {
|
|||||||
false,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
range,
|
range,
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const work = inputs.length
|
const work = inputs.length
|
||||||
@@ -154,7 +157,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,
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
@@ -174,6 +177,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
@@ -186,14 +190,12 @@ 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,
|
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
|
||||||
new Date(),
|
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
|
||||||
force,
|
if (!needsRefresh) {
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const reserved = await this.repository.reserve(
|
const reserved = await this.repository.reserve(
|
||||||
@@ -212,7 +214,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 });
|
||||||
@@ -241,6 +243,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']
|
||||||
@@ -263,7 +295,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>(
|
||||||
@@ -272,7 +304,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}`,
|
||||||
@@ -287,10 +319,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,
|
||||||
});
|
});
|
||||||
@@ -300,7 +329,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),
|
||||||
@@ -308,6 +337,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,
|
||||||
@@ -323,7 +353,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 {
|
||||||
@@ -470,6 +500,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) {
|
||||||
@@ -556,6 +587,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();
|
||||||
@@ -600,39 +639,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;
|
||||||
@@ -651,6 +657,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 (
|
||||||
[
|
[
|
||||||
@@ -663,9 +670,8 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
) {
|
) {
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
}
|
||||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
|
||||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
return 'stable';
|
||||||
return 'metadata';
|
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
return input.instrumentType === 'bond' &&
|
return input.instrumentType === 'bond' &&
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ const row = (
|
|||||||
isin: null,
|
isin: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
|
sector: null,
|
||||||
|
issuer: null,
|
||||||
});
|
});
|
||||||
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||||
positions,
|
positions,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -71,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}` },
|
||||||
@@ -88,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',
|
||||||
@@ -97,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;
|
||||||
@@ -137,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')
|
||||||
|
|||||||
@@ -2,8 +2,15 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
|
import {
|
||||||
|
claimSyncWork,
|
||||||
|
lockConnectionGeneration,
|
||||||
|
} from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
@@ -62,34 +69,30 @@ export class CandleRepository {
|
|||||||
priority = 1,
|
priority = 1,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey };
|
const scope = { ...owner, scopeKey };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
const joinable = isJoinableRunningAttempt(previous, runId, active)
|
||||||
previous?.state === 'running' &&
|
? previous
|
||||||
previous.processRunId === runId &&
|
: null;
|
||||||
active.has(previous.attemptId)
|
if (joinable) {
|
||||||
) {
|
const previousIntent = joinable.intent as Prisma.JsonObject;
|
||||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
|
||||||
const previousNow =
|
const previousNow =
|
||||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||||
const nextPriority = Math.min(previous.priority, priority);
|
const nextPriority = Math.min(joinable.priority, priority);
|
||||||
if (
|
if (
|
||||||
nextPriority === previous.priority &&
|
nextPriority === joinable.priority &&
|
||||||
(!intent.force ||
|
(!intent.force ||
|
||||||
(previousIntent.force === true &&
|
(previousIntent.force === true &&
|
||||||
(!intent.now || intent.now <= previousNow)))
|
(!intent.now || intent.now <= previousNow)))
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: joinable, launch: false };
|
||||||
}
|
}
|
||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: joinable.id },
|
||||||
data: {
|
data: {
|
||||||
priority: nextPriority,
|
priority: nextPriority,
|
||||||
intent: {
|
intent: {
|
||||||
@@ -163,15 +166,10 @@ export class CandleRepository {
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankSyncWork.updateMany({
|
const valid = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
progress: { lastWindow: key },
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { progress: { lastWindow: key } },
|
|
||||||
});
|
});
|
||||||
if (!valid.count) return false;
|
if (!valid) return false;
|
||||||
const identity = {
|
const identity = {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
attemptId: attempt.attemptId,
|
attemptId: attempt.attemptId,
|
||||||
@@ -196,18 +194,11 @@ export class CandleRepository {
|
|||||||
completeWork = true,
|
completeWork = true,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
...(completeWork ? { state: 'complete' } : {}),
|
||||||
...attempt,
|
errorCode: null,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
...(completeWork ? { state: 'complete' } : {}),
|
|
||||||
errorCode: null,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const staged = await tx.tbankSyncStage.findMany({
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
where: {
|
where: {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
@@ -311,28 +302,17 @@ export class CandleRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
const result = await tx.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
errorCode: null,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', errorCode: null },
|
|
||||||
});
|
});
|
||||||
return result.count > NO_UPDATED_ROWS;
|
return result > NO_UPDATED_ROWS;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, { state: 'failed', errorCode });
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode },
|
|
||||||
});
|
|
||||||
await tx.tbankSyncStage.deleteMany({
|
await tx.tbankSyncStage.deleteMany({
|
||||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
});
|
});
|
||||||
|
|||||||
+24
-46
@@ -3,9 +3,10 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -159,24 +160,18 @@ export class CatalogDiscoveryRepository {
|
|||||||
initiator: TbankSyncAttempt = attempt,
|
initiator: TbankSyncAttempt = attempt,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const origin = await tx.tbankSyncWork.updateMany({
|
const origin = await claimSyncWork(
|
||||||
where: {
|
tx,
|
||||||
...initiator,
|
initiator,
|
||||||
state: { in: ['running', 'complete'] },
|
{ progress: {} },
|
||||||
connection: { invalidToken: false },
|
{ state: { in: ['running', 'complete'] } },
|
||||||
},
|
);
|
||||||
data: { progress: {} },
|
if (!origin) return false;
|
||||||
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
|
state: 'complete',
|
||||||
|
progress: {},
|
||||||
});
|
});
|
||||||
if (!origin.count) return false;
|
if (!claimed) return false;
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
|
||||||
if (!claimed.count) return false;
|
|
||||||
const payload = JSON.parse(
|
const payload = JSON.parse(
|
||||||
JSON.stringify(value),
|
JSON.stringify(value),
|
||||||
) as Prisma.InputJsonObject;
|
) as Prisma.InputJsonObject;
|
||||||
@@ -284,20 +279,13 @@ export class CatalogDiscoveryRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey: catalogScope(input) };
|
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -345,15 +333,11 @@ export class CatalogDiscoveryRepository {
|
|||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
for (const instrument of instruments) {
|
for (const instrument of instruments) {
|
||||||
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||||
const identity = { provider, uid: instrument.uid };
|
const identity = { provider, uid: instrument.uid };
|
||||||
@@ -465,6 +449,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
|
|
||||||
finish(attempt: TbankSyncAttempt, success: boolean) {
|
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await tx.tbankSyncWork.updateMany({
|
await tx.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -473,16 +458,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: success ? 'complete' : 'failed',
|
||||||
...attempt,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,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({
|
||||||
@@ -25,6 +26,7 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
StockIncomeService,
|
StockIncomeService,
|
||||||
StockMarketService,
|
StockMarketService,
|
||||||
StockCatalogService,
|
StockCatalogService,
|
||||||
|
StockScreenerService,
|
||||||
BondCatalogService,
|
BondCatalogService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,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}` },
|
||||||
@@ -57,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',
|
||||||
@@ -66,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;
|
||||||
@@ -106,7 +106,7 @@ export class StockIncomeService {
|
|||||||
projected.observedAt,
|
projected.observedAt,
|
||||||
true,
|
true,
|
||||||
),
|
),
|
||||||
meta: { ...result.meta, availability: 'partial' },
|
meta: { ...result.meta, completeness: 'incomplete' },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
|
||||||
|
TbankAccessService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
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 as never, 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,138 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
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 { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
|
||||||
|
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 };
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class StockScreenerService {
|
||||||
|
private readonly snapshots = new Map<string, Snapshot>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly access: TbankAccessService,
|
||||||
|
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,34 @@ 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,
|
||||||
|
) {
|
||||||
|
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
|
||||||
|
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 +150,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', '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;
|
||||||
@@ -73,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());
|
||||||
@@ -36,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', () => {
|
||||||
@@ -60,8 +164,8 @@ 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],
|
||||||
|
|||||||
@@ -1,41 +1,34 @@
|
|||||||
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 === 'failed') ??
|
syncStates.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 ?? [])
|
||||||
@@ -57,16 +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 meta;
|
return Object.defineProperty(meta, 'sync', {
|
||||||
|
enumerable: false,
|
||||||
|
value: sourceWork,
|
||||||
|
}) as InvestmentResourceMetaDto;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceSync(work: Work) {
|
||||||
|
let state: 'failed' | 'idle' | 'running' = 'idle';
|
||||||
|
if (work?.state === 'running') state = 'running';
|
||||||
|
else if (work?.state === 'failed') state = 'failed';
|
||||||
|
return { id: work?.id ?? null, state, errorCode: work?.errorCode ?? null };
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,187 +1,16 @@
|
|||||||
import {
|
import { accountHistory, ledgerRange } from './investment-sync-plan';
|
||||||
accountHistory,
|
|
||||||
ledgerRange,
|
|
||||||
planInvestmentScreen,
|
|
||||||
} from './investment-sync-plan';
|
|
||||||
|
|
||||||
describe('screen dependency and history planning', () => {
|
// Missing opening dates must not silently narrow the reported history boundary.
|
||||||
|
describe('account history boundaries', () => {
|
||||||
const now = new Date('2026-09-25T12:00:00Z');
|
const now = new Date('2026-09-25T12:00:00Z');
|
||||||
const context = {
|
const accounts = [{ accountId: 'a', openedOn: null }];
|
||||||
membershipKnown: true,
|
|
||||||
accounts: [{ accountId: 'a', openedOn: '2020-01-01' }],
|
it('retains the unknown opening boundary for history and instrument ledger', () => {
|
||||||
instruments: [
|
expect(accountHistory(accounts, now)[0].from.toISOString()).toBe(
|
||||||
{
|
|
||||||
uid: 'uid',
|
|
||||||
type: 'STOCK' as const,
|
|
||||||
historyFrom: new Date('2020-01-01Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => {
|
|
||||||
const plan = planInvestmentScreen(
|
|
||||||
{
|
|
||||||
screen: 'income',
|
|
||||||
accountId: 'a',
|
|
||||||
from: '2024-02-01',
|
|
||||||
to: '2024-02-29',
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
expect(plan.sources[0].kind).toBe('membership');
|
|
||||||
const selected = plan.sources.filter(
|
|
||||||
(source) => source.kind === 'operations' && source.priority === 'screen',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
selected.some(
|
|
||||||
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
selected.some(
|
|
||||||
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(plan.unavailable).toEqual([
|
|
||||||
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
|
|
||||||
]);
|
|
||||||
const firstHistory = plan.sources.findIndex(
|
|
||||||
(source) => source.priority === 'history',
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
plan.sources
|
|
||||||
.slice(firstHistory)
|
|
||||||
.every((source) => source.priority === 'history'),
|
|
||||||
).toBe(true);
|
|
||||||
});
|
|
||||||
it('force contains only shown dependencies and retains instrument reconciliation predicates', () => {
|
|
||||||
const plan = planInvestmentScreen(
|
|
||||||
{
|
|
||||||
screen: 'stock',
|
|
||||||
instrumentUid: 'uid',
|
|
||||||
from: '2026-01-01',
|
|
||||||
to: '2026-02-01',
|
|
||||||
candlePeriod: 'year',
|
|
||||||
},
|
|
||||||
context,
|
|
||||||
now,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(plan.sources.map((source) => source.kind)).toEqual(
|
|
||||||
expect.arrayContaining([
|
|
||||||
'membership',
|
|
||||||
'portfolio',
|
|
||||||
'operations',
|
|
||||||
'metadata',
|
|
||||||
'quote',
|
|
||||||
'company',
|
|
||||||
'calendar',
|
|
||||||
'candles',
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
expect(plan.sources.every((source) => source.priority !== 'history')).toBe(
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
plan.sources
|
|
||||||
.filter((source) => source.kind === 'operations')
|
|
||||||
.every((source) => source.instrumentUid === 'uid'),
|
|
||||||
).toBe(true);
|
|
||||||
const accounts = planInvestmentScreen(
|
|
||||||
{ screen: 'accounts' },
|
|
||||||
context,
|
|
||||||
now,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
accounts.sources.some((source) => source.kind === 'operations'),
|
|
||||||
).toBe(false);
|
|
||||||
});
|
|
||||||
it('never invents an account or candle history boundary; discovery gates unknown membership', () => {
|
|
||||||
const unknown = {
|
|
||||||
...context,
|
|
||||||
accounts: [{ accountId: 'a', openedOn: null }],
|
|
||||||
instruments: [],
|
|
||||||
};
|
|
||||||
expect(accountHistory(unknown.accounts, now)[0].from.toISOString()).toBe(
|
|
||||||
'1970-01-01T00:00:00.000Z',
|
'1970-01-01T00:00:00.000Z',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
ledgerRange(
|
ledgerRange(undefined, undefined, 'uid', accounts, now).from.toISOString(),
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
'uid',
|
|
||||||
unknown.accounts,
|
|
||||||
now,
|
|
||||||
).from.toISOString(),
|
|
||||||
).toBe('1970-01-01T00:00:00.000Z');
|
).toBe('1970-01-01T00:00:00.000Z');
|
||||||
const plan = planInvestmentScreen(
|
|
||||||
{ screen: 'bond', instrumentUid: 'uid', candlePeriod: 'all' },
|
|
||||||
unknown,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
expect(plan.sources.some((source) => source.kind === 'candles')).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
expect(plan.unavailable[0].reason).toBe('history_start_unavailable');
|
|
||||||
expect(
|
|
||||||
planInvestmentScreen(
|
|
||||||
{ screen: 'account' },
|
|
||||||
{ ...context, membershipKnown: false },
|
|
||||||
now,
|
|
||||||
).sources,
|
|
||||||
).toEqual([{ kind: 'membership', priority: 'discovery' }]);
|
|
||||||
});
|
|
||||||
it('keeps UTC event days separate from Moscow instrument calendar days at year rollover', () => {
|
|
||||||
const rollover = new Date('2026-12-31T22:00:00Z');
|
|
||||||
const events = planInvestmentScreen(
|
|
||||||
{ screen: 'events' },
|
|
||||||
context,
|
|
||||||
rollover,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
events.sources.find((source) => source.kind === 'calendar')?.from,
|
|
||||||
).toEqual(new Date('2026-12-31Z'));
|
|
||||||
const stock = planInvestmentScreen(
|
|
||||||
{ screen: 'stock', instrumentUid: 'uid' },
|
|
||||||
context,
|
|
||||||
rollover,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
stock.sources.find((source) => source.kind === 'calendar'),
|
|
||||||
).toMatchObject({
|
|
||||||
from: new Date('2022-01-01Z'),
|
|
||||||
to: new Date('2028-01-01Z'),
|
|
||||||
});
|
|
||||||
expect(rollover.toISOString()).toBe('2026-12-31T22:00:00.000Z');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps search queries separate and events scoped to their inclusive period', () => {
|
|
||||||
const search = planInvestmentScreen(
|
|
||||||
{ screen: 'stocks', query: ' sber ' },
|
|
||||||
context,
|
|
||||||
now,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
search.sources.find((source) => source.kind === 'catalog')?.query,
|
|
||||||
).toBe('SBER');
|
|
||||||
const events = planInvestmentScreen(
|
|
||||||
{ screen: 'events', from: '2026-10-01', to: '2026-10-31' },
|
|
||||||
context,
|
|
||||||
now,
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
events.sources.find((source) => source.kind === 'calendar'),
|
|
||||||
).toMatchObject({
|
|
||||||
from: new Date('2026-10-01Z'),
|
|
||||||
to: new Date('2026-10-31Z'),
|
|
||||||
});
|
|
||||||
expect(events.sources.some((source) => source.kind === 'operations')).toBe(
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,288 +1,11 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import type { OperationInterval } from './operations/domain/operations-sync-plan';
|
||||||
import { planIncomePeriod } from './income/income.api';
|
|
||||||
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
|
||||||
import {
|
|
||||||
type OperationInterval,
|
|
||||||
operationIntervals,
|
|
||||||
} from './operations/domain/operations-sync-plan';
|
|
||||||
|
|
||||||
const FORECAST_YEARS = 1;
|
|
||||||
const JANUARY_INDEX = 0;
|
const JANUARY_INDEX = 0;
|
||||||
const FIRST_DAY_OF_MONTH = 1;
|
const FIRST_DAY_OF_MONTH = 1;
|
||||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
const EXCLUSIVE_END_OFFSET_MS = 1;
|
||||||
const NO_END_OFFSET_MS = 0;
|
const NO_END_OFFSET_MS = 0;
|
||||||
|
|
||||||
export type InvestmentScreen = {
|
|
||||||
accountId?: string;
|
|
||||||
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
|
|
||||||
candleRange?: { from: Date; to: Date };
|
|
||||||
from?: string;
|
|
||||||
instrumentUid?: string;
|
|
||||||
query?: string;
|
|
||||||
screen:
|
|
||||||
| 'account' |
|
|
||||||
'accounts' |
|
|
||||||
'bond' |
|
|
||||||
'bonds' |
|
|
||||||
'events' |
|
|
||||||
'income' |
|
|
||||||
'operations' |
|
|
||||||
'stock' |
|
|
||||||
'stocks';
|
|
||||||
to?: string;
|
|
||||||
};
|
|
||||||
const defaultEventDays = 30;
|
|
||||||
const incomeHistoryYears = 5;
|
|
||||||
const candleMonths = { month: 1, halfYear: 6, year: 12 };
|
|
||||||
|
|
||||||
type Account = { accountId: string; openedOn: null | string };
|
type Account = { accountId: string; openedOn: null | string };
|
||||||
type Instrument = {
|
|
||||||
historyFrom: Date | null;
|
|
||||||
type: 'BOND' | 'STOCK';
|
|
||||||
uid: string;
|
|
||||||
};
|
|
||||||
type Source = {
|
|
||||||
accountId?: string;
|
|
||||||
from?: Date;
|
|
||||||
instrumentType?: 'BOND' | 'STOCK';
|
|
||||||
instrumentUid?: string;
|
|
||||||
kind:
|
|
||||||
| 'calendar' |
|
|
||||||
'candles' |
|
|
||||||
'catalog' |
|
|
||||||
'company' |
|
|
||||||
'membership' |
|
|
||||||
'metadata' |
|
|
||||||
'metrics' |
|
|
||||||
'operations' |
|
|
||||||
'portfolio' |
|
|
||||||
'quote';
|
|
||||||
priority: 'discovery' | 'history' | 'screen';
|
|
||||||
query?: string;
|
|
||||||
to?: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
|
|
||||||
// Owners execute their own sources and expand instruments after discovery.
|
|
||||||
export function planInvestmentScreen(
|
|
||||||
screen: InvestmentScreen,
|
|
||||||
context: {
|
|
||||||
accounts: Account[];
|
|
||||||
instruments: Instrument[];
|
|
||||||
membershipKnown: boolean;
|
|
||||||
},
|
|
||||||
now: Date,
|
|
||||||
force = false,
|
|
||||||
) {
|
|
||||||
const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }];
|
|
||||||
const accounts = context.accounts.filter(
|
|
||||||
(account) =>
|
|
||||||
!screen.accountId ||
|
|
||||||
screen.accountId === 'all' ||
|
|
||||||
account.accountId === screen.accountId,
|
|
||||||
);
|
|
||||||
const unavailable: { reason: string; resourceKey: string }[] = [];
|
|
||||||
const operations = (from: Date, to: Date, instrumentUid?: string) => {
|
|
||||||
const targets = accounts.flatMap((account) =>
|
|
||||||
from < to
|
|
||||||
? [
|
|
||||||
{
|
|
||||||
accountId: account.accountId,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
...(instrumentUid ? { instrumentUid } : {}),
|
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
sources.push(
|
|
||||||
...operationIntervals(targets).map((range) => ({
|
|
||||||
...range,
|
|
||||||
kind: 'operations' as const,
|
|
||||||
priority: 'screen' as const,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
const portfolio = () =>
|
|
||||||
sources.push(
|
|
||||||
...accounts.map(({ accountId }) => ({
|
|
||||||
kind: 'portfolio' as const,
|
|
||||||
accountId,
|
|
||||||
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 (
|
|
||||||
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
|
|
||||||
) {
|
|
||||||
portfolio();
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
screen.screen === 'operations' ||
|
|
||||||
screen.screen === 'stock' ||
|
|
||||||
screen.screen === 'bond'
|
|
||||||
) {
|
|
||||||
const range = ledgerRange(
|
|
||||||
screen.from,
|
|
||||||
screen.to,
|
|
||||||
screen.instrumentUid,
|
|
||||||
accounts,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
operations(range.from, range.to, force ? screen.instrumentUid : undefined);
|
|
||||||
}
|
|
||||||
if (screen.screen === 'account' || screen.screen === 'income') {
|
|
||||||
const period = planIncomePeriod(
|
|
||||||
screen.screen === 'income' ? screen.from : undefined,
|
|
||||||
screen.screen === 'income' ? screen.to : undefined,
|
|
||||||
knownOpening,
|
|
||||||
);
|
|
||||||
for (const range of [
|
|
||||||
period.selected,
|
|
||||||
...(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') {
|
|
||||||
const range = ledgerRange(
|
|
||||||
screen.from,
|
|
||||||
screen.to,
|
|
||||||
undefined,
|
|
||||||
accounts,
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
operations(range.from, range.to);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (screen.screen === 'stocks' || screen.screen === 'bonds') {
|
|
||||||
sources.push({
|
|
||||||
kind: 'catalog',
|
|
||||||
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
|
|
||||||
query: screen.query?.trim().toUpperCase() ?? '',
|
|
||||||
priority: 'screen',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const instruments = context.instruments.filter(
|
|
||||||
(instrument) =>
|
|
||||||
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
screen.instrumentUid &&
|
|
||||||
!instruments.length &&
|
|
||||||
(screen.screen === 'stock' || screen.screen === 'bond')
|
|
||||||
) {
|
|
||||||
instruments.push({
|
|
||||||
uid: screen.instrumentUid,
|
|
||||||
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
|
|
||||||
historyFrom: null,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (const instrument of instruments) {
|
|
||||||
const identity = {
|
|
||||||
instrumentUid: instrument.uid,
|
|
||||||
instrumentType: instrument.type,
|
|
||||||
};
|
|
||||||
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
|
|
||||||
if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) {
|
|
||||||
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
|
|
||||||
}
|
|
||||||
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: screen.screen === 'stock' ? 'company' : 'metrics',
|
|
||||||
priority: 'screen',
|
|
||||||
});
|
|
||||||
const from = new Date(now);
|
|
||||||
const period = screen.candlePeriod ?? 'month';
|
|
||||||
if (period !== 'all') {
|
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union.
|
|
||||||
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
|
|
||||||
}
|
|
||||||
const range =
|
|
||||||
screen.candleRange ??
|
|
||||||
(period === 'all'
|
|
||||||
? instrument.historyFrom
|
|
||||||
? { from: instrument.historyFrom, to: now }
|
|
||||||
: null
|
|
||||||
: { from, to: now });
|
|
||||||
if (range) {
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: 'candles',
|
|
||||||
...range,
|
|
||||||
priority: 'screen',
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
unavailable.push({
|
|
||||||
resourceKey: `candles/${instrument.uid}/all`,
|
|
||||||
reason: 'history_start_unavailable',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
|
|
||||||
const day =
|
|
||||||
screen.screen === 'stock' || screen.screen === 'bond'
|
|
||||||
? moscowDate(now)
|
|
||||||
: now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
|
||||||
const from =
|
|
||||||
screen.screen === 'events' && screen.from
|
|
||||||
? new Date(`${screen.from}T00:00:00Z`)
|
|
||||||
: new Date(`${day}T00:00:00Z`);
|
|
||||||
const to =
|
|
||||||
screen.screen === 'events' && screen.to
|
|
||||||
? new Date(`${screen.to}T00:00:00Z`)
|
|
||||||
: new Date(from);
|
|
||||||
if (
|
|
||||||
!(screen.screen === 'events' && screen.to) &&
|
|
||||||
screen.screen !== 'stock' &&
|
|
||||||
screen.screen !== 'bond'
|
|
||||||
) {
|
|
||||||
to.setUTCDate(to.getUTCDate() + defaultEventDays);
|
|
||||||
}
|
|
||||||
if (screen.screen === 'stock' || screen.screen === 'bond') {
|
|
||||||
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
|
|
||||||
to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS);
|
|
||||||
}
|
|
||||||
sources.push({
|
|
||||||
...identity,
|
|
||||||
kind: 'calendar',
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
priority: 'screen',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
|
|
||||||
sources.push(
|
|
||||||
...accountHistory(accounts, now).map((range) => ({
|
|
||||||
...range,
|
|
||||||
kind: 'operations' as const,
|
|
||||||
priority: 'history' as const,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const priorities = { discovery: 0, screen: 1, history: 2 };
|
|
||||||
sources.sort(
|
|
||||||
(left, right) => priorities[left.priority] - priorities[right.priority],
|
|
||||||
);
|
|
||||||
return { sources, unavailable, now };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function accountHistory(
|
export function accountHistory(
|
||||||
accounts: Account[],
|
accounts: Account[],
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const operationCategories = [
|
||||||
|
'trade',
|
||||||
|
'income',
|
||||||
|
'tax',
|
||||||
|
'fee',
|
||||||
|
'transfer',
|
||||||
|
'other',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OperationCategory = (typeof operationCategories)[number];
|
||||||
+6
-27
@@ -6,13 +6,11 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
|||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||||
import { accountHistory } from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
uncoveredOperationIntervals,
|
|
||||||
} from '../domain/operations-sync-plan';
|
} from '../domain/operations-sync-plan';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsRepository,
|
BrokerageOperationsRepository,
|
||||||
@@ -48,7 +46,6 @@ export class BrokerageOperationsSyncService {
|
|||||||
intervals: OperationInterval[],
|
intervals: OperationInterval[],
|
||||||
options: {
|
options: {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
history?: boolean;
|
|
||||||
movingEnd?: boolean;
|
movingEnd?: boolean;
|
||||||
observe?: boolean;
|
observe?: boolean;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
@@ -99,30 +96,6 @@ export class BrokerageOperationsSyncService {
|
|||||||
connection.revision,
|
connection.revision,
|
||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
if (!options.observe && !options.force && options.history) {
|
|
||||||
const accountIds = new Set(
|
|
||||||
intervals.map((interval) => interval.accountId),
|
|
||||||
);
|
|
||||||
const history = accountHistory(
|
|
||||||
connection.accounts.filter((account) =>
|
|
||||||
accountIds.has(account.accountId),
|
|
||||||
),
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
const missing = planOperationRead(history, coverage, now, {
|
|
||||||
movingEnd: true,
|
|
||||||
}).load;
|
|
||||||
const remaining = uncoveredOperationIntervals(missing, targets);
|
|
||||||
if (remaining.length) {
|
|
||||||
await this.request(
|
|
||||||
userId,
|
|
||||||
remaining,
|
|
||||||
false,
|
|
||||||
'history',
|
|
||||||
connection.revision,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { plan, handle };
|
return { plan, handle };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,6 +182,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) {
|
||||||
|
|||||||
@@ -6,17 +6,16 @@ import {
|
|||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankMembershipService,
|
|
||||||
type TbankOperationItem,
|
type TbankOperationItem,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import { accountHistory, ledgerRange } from '../../investment-sync-plan';
|
||||||
import {
|
import {
|
||||||
accountHistory,
|
hourlyAttempt,
|
||||||
ledgerRange,
|
TbankSyncRepository,
|
||||||
planInvestmentScreen,
|
} from '../../tbank-sync/tbank-sync.api';
|
||||||
} from '../../investment-sync-plan';
|
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
confirmedOperationRanges,
|
confirmedOperationRanges,
|
||||||
@@ -33,7 +32,6 @@ import {
|
|||||||
} from '../presentation/brokerage-operation.dto';
|
} from '../presentation/brokerage-operation.dto';
|
||||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||||
|
|
||||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
const FIRST_RESULT_OFFSET = 0;
|
||||||
const MISSING_MEMBERSHIP_REVISION = 0;
|
const MISSING_MEMBERSHIP_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
const STALE_FRESHNESS_MS = 0;
|
||||||
@@ -57,8 +55,8 @@ export class BrokerageOperationsService {
|
|||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: BrokerageOperationsRepository,
|
private readonly repository: BrokerageOperationsRepository,
|
||||||
private readonly sync: BrokerageOperationsSyncService,
|
private readonly sync: BrokerageOperationsSyncService,
|
||||||
private readonly membership: TbankMembershipService,
|
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly hourlySync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(
|
async read(
|
||||||
@@ -112,75 +110,19 @@ export class BrokerageOperationsService {
|
|||||||
cursor,
|
cursor,
|
||||||
};
|
};
|
||||||
if (cursor) await this.repository.readPage(readInput);
|
if (cursor) await this.repository.readPage(readInput);
|
||||||
const screen = planInvestmentScreen(
|
const intervals = selected.flatMap((account) =>
|
||||||
{
|
from < to ? [{ accountId: account.accountId, from, to }] : [],
|
||||||
screen: 'operations',
|
|
||||||
accountId,
|
|
||||||
from: from.toISOString(),
|
|
||||||
to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accounts: selected,
|
|
||||||
instruments: [],
|
|
||||||
membershipKnown: Boolean(connection.membershipAt),
|
|
||||||
},
|
|
||||||
now,
|
|
||||||
);
|
);
|
||||||
const intervals = screen.sources.flatMap((source) =>
|
// The ledger only starts the first-time load of missing intervals; the
|
||||||
source.kind === 'operations' &&
|
// mutable tail belongs to the hourly walk and membership is not rechecked.
|
||||||
source.priority === 'screen' &&
|
if (intervals.length) {
|
||||||
source.accountId &&
|
await this.sync.readPlan(userId, intervals, {
|
||||||
source.from &&
|
movingEnd,
|
||||||
source.to
|
revision: connection.revision,
|
||||||
? [{ accountId: source.accountId, from: source.from, to: source.to }]
|
});
|
||||||
: [],
|
|
||||||
);
|
|
||||||
if (query.sync !== '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;
|
|
||||||
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
|
||||||
@@ -252,26 +194,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),
|
||||||
@@ -295,6 +221,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),
|
||||||
@@ -346,7 +273,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,
|
||||||
@@ -451,35 +378,23 @@ export class BrokerageOperationsService {
|
|||||||
periods: ranges,
|
periods: ranges,
|
||||||
};
|
};
|
||||||
let saved = await this.repository.readPage(input);
|
let saved = await this.repository.readPage(input);
|
||||||
const schedule = async () => {
|
if (!observe) {
|
||||||
const current = await this.access.local(userId, accountId);
|
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||||
if (current.revision !== connection.revision) return;
|
(saved?.periods ?? []).map((period) => ({
|
||||||
const snapshot = await this.repository.readPage(input);
|
|
||||||
if (!snapshot?.connection.membershipAt) return;
|
|
||||||
const targets = snapshot.connection.accounts.flatMap((account) =>
|
|
||||||
(snapshot.periods ?? []).map((period) => ({
|
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
...period,
|
...period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
await this.sync.readPlan(userId, targets, {
|
await this.sync.readPlan(userId, targets, {
|
||||||
movingEnd: snapshot.periods?.some((period) => period.movingEnd),
|
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
||||||
history: true,
|
|
||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
|
||||||
if (!observe) {
|
|
||||||
const discovery = await this.membership.request(
|
|
||||||
userId,
|
|
||||||
connection.revision,
|
|
||||||
);
|
|
||||||
if (discovery) void discovery.done.then(schedule).catch(() => undefined);
|
|
||||||
else await schedule();
|
|
||||||
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),
|
||||||
@@ -496,7 +411,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,
|
||||||
@@ -532,8 +451,7 @@ export class BrokerageOperationsService {
|
|||||||
null;
|
null;
|
||||||
const confirmed = confirmedOperationRanges(plan.intervals, sources);
|
const confirmed = confirmedOperationRanges(plan.intervals, sources);
|
||||||
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'
|
||||||
@@ -545,6 +463,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),
|
||||||
@@ -587,7 +506,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) =>
|
||||||
@@ -601,6 +520,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: {
|
||||||
|
|||||||
@@ -71,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);
|
||||||
@@ -85,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 }] : [],
|
[],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ 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[],
|
||||||
@@ -188,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 {
|
||||||
|
|||||||
@@ -3,10 +3,8 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
|||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||||
import {
|
import { type OperationCategory } from '../../operation-category';
|
||||||
BrokerageOperationDto,
|
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
|
||||||
OperationCategory,
|
|
||||||
} from '../presentation/brokerage-operation.dto';
|
|
||||||
|
|
||||||
const MISSING_TIMESTAMP_COMPONENT = 0;
|
const MISSING_TIMESTAMP_COMPONENT = 0;
|
||||||
|
|
||||||
|
|||||||
+11
-25
@@ -7,13 +7,14 @@ import { createHash, randomUUID } from 'node:crypto';
|
|||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
import type {
|
|
||||||
TbankOperationItem,
|
|
||||||
TbankOperationsByCursorResponse,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim';
|
||||||
|
import {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
|
type TbankOperationItem, type TbankOperationsByCursorResponse,
|
||||||
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
dateOnlyBounds,
|
dateOnlyBounds,
|
||||||
@@ -268,16 +269,10 @@ export class BrokerageOperationsRepository {
|
|||||||
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const locked = await tx.tbankConnection.updateMany({
|
const locked = await lockConnectionGeneration(tx, { userId, revision }, {
|
||||||
where: {
|
membershipAt: { not: null },
|
||||||
userId,
|
|
||||||
revision,
|
|
||||||
invalidToken: false,
|
|
||||||
membershipAt: { not: null },
|
|
||||||
},
|
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
});
|
||||||
if (!locked.count) return null;
|
if (!locked) return null;
|
||||||
const accounts = await tx.tbankAccount.findMany({
|
const accounts = await tx.tbankAccount.findMany({
|
||||||
where: { userId, revision },
|
where: { userId, revision },
|
||||||
select: { accountId: true, openedOn: true },
|
select: { accountId: true, openedOn: true },
|
||||||
@@ -379,9 +374,7 @@ export class BrokerageOperationsRepository {
|
|||||||
publicationAtomic && previousIntent && !previousIntent.atomic;
|
publicationAtomic && previousIntent && !previousIntent.atomic;
|
||||||
if (
|
if (
|
||||||
touching.length <= SINGLE_WORK_GROUP &&
|
touching.length <= SINGLE_WORK_GROUP &&
|
||||||
previous?.state === 'running' &&
|
isJoinableRunningAttempt(previous, processRunId, runningAttempts) &&
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
runningAttempts.has(previous.attemptId) &&
|
|
||||||
!upgradeGroup
|
!upgradeGroup
|
||||||
) {
|
) {
|
||||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||||
@@ -933,15 +926,8 @@ async function claim(
|
|||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
attempt: OperationAttempt,
|
attempt: OperationAttempt,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const result = await tx.tbankSyncWork.updateMany({
|
const count = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||||
where: {
|
return count === EXPECTED_UPDATED_WORK_ITEMS;
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
return result.count === EXPECTED_UPDATED_WORK_ITEMS;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function decodeInterval(key: string): OperationInterval {
|
function decodeInterval(key: string): OperationInterval {
|
||||||
|
|||||||
@@ -5,4 +5,3 @@ export type { OperationInterval } from './domain/operations-sync-plan';
|
|||||||
|
|
||||||
// Public cross-module surface. Nest providers are exported by the matching module.
|
// Public cross-module surface. Nest providers are exported by the matching module.
|
||||||
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
|
||||||
export { operationCategories } from './presentation/brokerage-operation.dto';
|
|
||||||
|
|||||||
@@ -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,8 @@ 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,
|
import { operationCategories, type OperationCategory } from '../../operation-category';
|
||||||
InvestmentResourceMetaDto,
|
|
||||||
} from '../../investment-resource.dto';
|
|
||||||
|
|
||||||
const MIN_OPERATIONS_PAGE_SIZE = 1;
|
const MIN_OPERATIONS_PAGE_SIZE = 1;
|
||||||
|
|
||||||
@@ -26,17 +24,7 @@ const MAX_CURSOR_LENGTH = 4096;
|
|||||||
const MAX_OPERATIONS_PAGE_SIZE = 1000;
|
const MAX_OPERATIONS_PAGE_SIZE = 1000;
|
||||||
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
|
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
|
||||||
|
|
||||||
export const operationCategories = [
|
export class BrokerageOperationsQueryDto {
|
||||||
'trade',
|
|
||||||
'income',
|
|
||||||
'tax',
|
|
||||||
'fee',
|
|
||||||
'transfer',
|
|
||||||
'other',
|
|
||||||
] as const;
|
|
||||||
export type OperationCategory = (typeof operationCategories)[number];
|
|
||||||
|
|
||||||
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto {
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'uuid',
|
format: 'uuid',
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
|
|
||||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||||
|
|
||||||
|
import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
ForecastRepository,
|
ForecastRepository,
|
||||||
@@ -16,8 +17,10 @@ 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;
|
||||||
|
replacesLatest?: boolean;
|
||||||
revision: string;
|
revision: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
sources: {
|
sources: {
|
||||||
@@ -72,22 +75,53 @@ export class ForecastService {
|
|||||||
.map((dependency) => dependency.sync)
|
.map((dependency) => dependency.sync)
|
||||||
.find((sync) => sync.state === 'failed');
|
.find((sync) => sync.state === 'failed');
|
||||||
return {
|
return {
|
||||||
data: stored ? (stored.payload as T) : null,
|
data: stored ? filterEvents(stored.payload as T, input.scopeKey, stored.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: Number.POSITIVE_INFINITY,
|
freshForMs: 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, savedScopeKey: 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)));
|
||||||
|
}
|
||||||
|
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
|
||||||
|
return {
|
||||||
|
...value,
|
||||||
|
window: { from: savedFrom, to: savedTo },
|
||||||
|
events,
|
||||||
|
incomeTotalsByCurrency: [...totals]
|
||||||
|
.sort(([left], [right]) => left.localeCompare(right))
|
||||||
|
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { setImmediate as immediate } from 'node:timers/promises';
|
import assert from 'node:assert/strict';
|
||||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
@@ -6,9 +6,10 @@ import { PortfolioDto } from '../presentation/portfolio.dto';
|
|||||||
import { PortfolioService } from './portfolio.service';
|
import { PortfolioService } from './portfolio.service';
|
||||||
|
|
||||||
describe('persistent portfolio projection', () => {
|
describe('persistent portfolio projection', () => {
|
||||||
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
|
const access = { local: jest.fn(), tokenForAttempt: jest.fn(), rejectMembershipToken: jest.fn() };
|
||||||
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
|
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
|
||||||
const metadata = { savedMany: jest.fn(), request: jest.fn() };
|
const metadata = { savedMany: jest.fn(), request: jest.fn() };
|
||||||
|
const classification = { refresh: jest.fn() };
|
||||||
const repository = {
|
const repository = {
|
||||||
read: jest.fn(),
|
read: jest.fn(),
|
||||||
reserve: jest.fn(),
|
reserve: jest.fn(),
|
||||||
@@ -23,9 +24,11 @@ describe('persistent portfolio projection', () => {
|
|||||||
access as never,
|
access as never,
|
||||||
tbank as never,
|
tbank as never,
|
||||||
metadata as never,
|
metadata as never,
|
||||||
|
classification 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,
|
||||||
|
{ hourly: jest.fn().mockResolvedValue(null) } as never,
|
||||||
);
|
);
|
||||||
access.local.mockResolvedValue({
|
access.local.mockResolvedValue({
|
||||||
revision: 'private',
|
revision: 'private',
|
||||||
@@ -33,6 +36,7 @@ describe('persistent portfolio projection', () => {
|
|||||||
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
|
||||||
});
|
});
|
||||||
access.tokenForAttempt.mockResolvedValue('synthetic');
|
access.tokenForAttempt.mockResolvedValue('synthetic');
|
||||||
|
access.rejectMembershipToken.mockResolvedValue(undefined);
|
||||||
repository.reserve.mockResolvedValue(null);
|
repository.reserve.mockResolvedValue(null);
|
||||||
repository.read.mockResolvedValue({
|
repository.read.mockResolvedValue({
|
||||||
membershipAt,
|
membershipAt,
|
||||||
@@ -40,8 +44,40 @@ 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);
|
||||||
|
classification.refresh.mockResolvedValue(new Map());
|
||||||
|
});
|
||||||
|
it('refreshes classification before publishing and passes the hourly walk time', async () => {
|
||||||
|
const walkStartedAt = new Date('2026-10-31T12:00:00Z');
|
||||||
|
const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: '1' } };
|
||||||
|
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [position] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||||
|
classification.refresh.mockResolvedValue(new Map([[JSON.stringify(['uid', 'figi']), {
|
||||||
|
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
|
||||||
|
}]]));
|
||||||
|
const handle = await service.request('user', 'private', 'a', true, walkStartedAt);
|
||||||
|
assert(handle);
|
||||||
|
await handle.done;
|
||||||
|
expect(classification.refresh).toHaveBeenCalledWith('synthetic', [position], walkStartedAt);
|
||||||
|
expect(classification.refresh.mock.invocationCallOrder[0]).toBeLessThan(repository.publish.mock.invocationCallOrder[0]);
|
||||||
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
|
expect(snapshot.positions[0]).toEqual(expect.objectContaining({
|
||||||
|
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
it('rejects an invalid classification token without publishing the snapshot', async () => {
|
||||||
|
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [] });
|
||||||
|
classification.refresh.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
|
||||||
|
const handle = await service.request('user', 'private', 'a');
|
||||||
|
assert(handle);
|
||||||
|
await handle.done;
|
||||||
|
expect(repository.publish).not.toHaveBeenCalled();
|
||||||
|
expect(access.rejectMembershipToken).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
|
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
|
||||||
repository.reserve.mockResolvedValueOnce({
|
repository.reserve.mockResolvedValueOnce({
|
||||||
@@ -95,14 +131,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];
|
||||||
@@ -145,10 +176,9 @@ describe('persistent portfolio projection', () => {
|
|||||||
{ units: '99' },
|
{ units: '99' },
|
||||||
{ currency: 'EUR', units: '1' },
|
{ currency: 'EUR', units: '1' },
|
||||||
] });
|
] });
|
||||||
await service.request('user', 'private', 'a');
|
const handle = await service.request('user', 'private', 'a');
|
||||||
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
|
assert(handle);
|
||||||
await immediate();
|
await handle.done;
|
||||||
}
|
|
||||||
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
|
||||||
expect(snapshot.cashBalances).toEqual([
|
expect(snapshot.cashBalances).toEqual([
|
||||||
{ currency: 'RUB', value: '0' },
|
{ currency: 'RUB', value: '0' },
|
||||||
@@ -163,6 +193,43 @@ describe('persistent portfolio projection', () => {
|
|||||||
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
|
||||||
|
repository.reserve.mockResolvedValueOnce({
|
||||||
|
launch: true,
|
||||||
|
work: { id: 'work', attemptId: 'attempt' },
|
||||||
|
});
|
||||||
|
tbank.getPortfolio.mockResolvedValue({ positions: [
|
||||||
|
{ figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } },
|
||||||
|
{ figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } },
|
||||||
|
] });
|
||||||
|
tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] });
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
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.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
|
||||||
|
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
|
||||||
|
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
|
||||||
|
it('rejects a stored snapshot payload without the persisted shape', async () => {
|
||||||
|
repository.read.mockResolvedValue({
|
||||||
|
membershipAt,
|
||||||
|
membershipRevision: 1,
|
||||||
|
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null,
|
||||||
|
portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }],
|
||||||
|
work: [],
|
||||||
|
});
|
||||||
|
await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload');
|
||||||
|
});
|
||||||
|
|
||||||
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(),
|
||||||
@@ -189,10 +256,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,
|
||||||
);
|
);
|
||||||
@@ -210,7 +278,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' },
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
type MetadataIdentity,
|
|
||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -18,17 +19,24 @@ import {
|
|||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import {
|
||||||
|
hourlyAttempt,
|
||||||
|
TbankSyncRepository,
|
||||||
|
} from '../../tbank-sync/tbank-sync.api';
|
||||||
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 {
|
||||||
portfolioFreshForMs,
|
portfolioFreshForMs,
|
||||||
PortfolioRepository,
|
PortfolioRepository,
|
||||||
|
portfolioSnapshot,
|
||||||
portfolioWorkKey,
|
portfolioWorkKey,
|
||||||
} from '../infrastructure/portfolio.repository';
|
} from '../infrastructure/portfolio.repository';
|
||||||
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
|
||||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
import { type Classification, PositionClassificationService } from './position-classification.service';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
|
const NO_ACCOUNTS = 0;
|
||||||
|
|
||||||
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
const membershipFreshForMs = tbankSourceTtlMs.membership;
|
||||||
|
|
||||||
@@ -42,53 +50,18 @@ export class PortfolioService {
|
|||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
|
private readonly classification: PositionClassificationService,
|
||||||
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 sync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async saved(userId: string, accountId: string, observe = false) {
|
// Local reads never start upstream work; the hourly walk owns refreshes.
|
||||||
|
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' &&
|
||||||
@@ -96,26 +69,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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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),
|
||||||
@@ -123,8 +76,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(
|
||||||
@@ -136,13 +91,7 @@ export class PortfolioService {
|
|||||||
type: account.type,
|
type: account.type,
|
||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
snapshot: account.portfolio
|
snapshot: account.portfolio
|
||||||
? {
|
? savedSnapshot(account.portfolio)
|
||||||
...(account.portfolio.payload as unknown as PortfolioDto),
|
|
||||||
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
|
|
||||||
isStale:
|
|
||||||
Date.now() - account.portfolio.fetchedAt.valueOf() >=
|
|
||||||
portfolioFreshForMs,
|
|
||||||
}
|
|
||||||
: null,
|
: null,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
|
||||||
@@ -154,16 +103,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 ?? []),
|
||||||
);
|
);
|
||||||
@@ -176,7 +130,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,
|
||||||
}),
|
}),
|
||||||
]
|
]
|
||||||
@@ -212,6 +166,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] ??
|
||||||
@@ -255,16 +214,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),
|
||||||
@@ -274,8 +230,8 @@ export class PortfolioService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
request(userId: string, revision: string, accountId: string, force = false) {
|
request(userId: string, revision: string, accountId: string, force = false, walkStartedAt?: Date) {
|
||||||
return this.queueSnapshot(userId, revision, accountId, force);
|
return this.queueSnapshot(userId, revision, accountId, force, walkStartedAt);
|
||||||
}
|
}
|
||||||
|
|
||||||
private queueSnapshot(
|
private queueSnapshot(
|
||||||
@@ -283,6 +239,7 @@ export class PortfolioService {
|
|||||||
revision: string,
|
revision: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
force = false,
|
force = false,
|
||||||
|
walkStartedAt = new Date(),
|
||||||
) {
|
) {
|
||||||
const reserved = this.reservations.then(async () => {
|
const reserved = this.reservations.then(async () => {
|
||||||
const requested = await this.repository.reserve(
|
const requested = await this.repository.reserve(
|
||||||
@@ -311,9 +268,11 @@ export class PortfolioService {
|
|||||||
this.tbank.getPortfolio(token, accountId),
|
this.tbank.getPortfolio(token, accountId),
|
||||||
this.tbank.getPositions(token, accountId),
|
this.tbank.getPositions(token, accountId),
|
||||||
]);
|
]);
|
||||||
|
const classifications = await this.classification.refresh(token, portfolio.positions ?? [], walkStartedAt);
|
||||||
const snapshot = await this.normalize(
|
const snapshot = await this.normalize(
|
||||||
portfolio.positions ?? [],
|
portfolio.positions ?? [],
|
||||||
positions.money ?? [],
|
positions.money ?? [],
|
||||||
|
classifications,
|
||||||
);
|
);
|
||||||
const published = await this.repository.publish(
|
const published = await this.repository.publish(
|
||||||
attempt,
|
attempt,
|
||||||
@@ -352,9 +311,24 @@ export class PortfolioService {
|
|||||||
private async normalize(
|
private async normalize(
|
||||||
upstream: NonNullable<TbankPortfolio['positions']>,
|
upstream: NonNullable<TbankPortfolio['positions']>,
|
||||||
money: TbankMoney[],
|
money: TbankMoney[],
|
||||||
|
classifications: ReadonlyMap<string, Classification>,
|
||||||
): 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);
|
||||||
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
|
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto {
|
||||||
|
const snapshot = portfolioSnapshot(saved.payload);
|
||||||
|
return {
|
||||||
|
...snapshot,
|
||||||
|
positions: snapshot.positions.map((position) => ({
|
||||||
|
...position,
|
||||||
|
sector: position.sector ?? null,
|
||||||
|
issuer: position.issuer ?? null,
|
||||||
|
})),
|
||||||
|
lastSuccessfulAt: saved.fetchedAt.toISOString(),
|
||||||
|
isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
+172
@@ -0,0 +1,172 @@
|
|||||||
|
import { NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||||
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
|
import { PositionClassificationService } from './position-classification.service';
|
||||||
|
|
||||||
|
describe('PositionClassificationService', () => {
|
||||||
|
const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: 1 } };
|
||||||
|
const saved = { sector: 'Old', sectorFetchedAt: new Date('2026-10-30T12:00:00Z'), assetUid: 'asset', brandUid: 'brand', brandName: 'Old brand', brandFetchedAt: new Date('2026-10-30T12:00:00Z') };
|
||||||
|
const tbank = { getShare: jest.fn(), getAssetBrand: jest.fn(), getBond: jest.fn(), getEtf: jest.fn() };
|
||||||
|
const repository = { read: jest.fn(), save: jest.fn() };
|
||||||
|
const logger = { event: jest.fn() };
|
||||||
|
const service = new PositionClassificationService(tbank as never, repository as never, logger as never);
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.resetAllMocks();
|
||||||
|
repository.read.mockResolvedValue(saved);
|
||||||
|
repository.save.mockImplementation((_key: string, update: Record<string, unknown>) => Promise.resolve({ ...saved, ...update }));
|
||||||
|
tbank.getAssetBrand.mockResolvedValue({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch a value at 23:59, and refreshes it at 24:00', async () => {
|
||||||
|
await service.refresh('token', [position], new Date('2026-10-31T11:59:00Z'));
|
||||||
|
expect(tbank.getShare).not.toHaveBeenCalled();
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', figi: 'figi', sector: ' Tech ', assetUid: 'asset' });
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.values().next().value).toEqual({ sector: 'Tech', issuer: { id: 'new-brand', name: 'New brand' } });
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), {
|
||||||
|
sector: 'Tech', sectorFetchedAt: new Date('2026-10-31T12:00:00Z'),
|
||||||
|
brandUid: 'new-brand', brandName: 'New brand', brandFetchedAt: new Date('2026-10-31T12:00:00Z'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a matching FIGI after UID NOT_FOUND', async () => {
|
||||||
|
tbank.getShare.mockRejectedValueOnce(new NotFoundException()).mockResolvedValueOnce({ uid: 'different', figi: 'figi', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(tbank.getShare).toHaveBeenNthCalledWith(2, 'token', 'figi', InstrumentIdType.Figi, true);
|
||||||
|
expect(result.values().next().value?.sector).toBe('Energy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves saved values on mismatched instrument and returns refreshed values on database failure', async () => {
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'wrong', sector: 'Energy' });
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.values().next().value?.sector).toBe('Old');
|
||||||
|
repository.save.mockRejectedValue(new Error('db unavailable'));
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
const fallback = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(fallback.values().next().value?.sector).toBe('Energy');
|
||||||
|
expect(logger.event).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retain an issuer from a previous asset when the asset changes', async () => {
|
||||||
|
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
|
||||||
|
tbank.getAssetBrand.mockResolvedValue({ assetUid: 'other', brand: { uid: 'other-brand', name: 'Other brand' } });
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(tbank.getAssetBrand).toHaveBeenCalledWith('token', 'other');
|
||||||
|
expect(result.values().next().value?.issuer).toEqual({ id: 'other-brand', name: 'Other brand' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not restart the brand clock when the brand fetch fails after an asset change', async () => {
|
||||||
|
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
|
||||||
|
tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable'));
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.values().next().value?.issuer).toBeNull();
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||||
|
assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null,
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not return the previous asset brand when saving a changed asset fails', async () => {
|
||||||
|
repository.save.mockRejectedValue(new Error('db unavailable'));
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
|
||||||
|
tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable'));
|
||||||
|
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.values().next().value).toEqual({ sector: 'Energy', issuer: null });
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||||
|
assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null,
|
||||||
|
}));
|
||||||
|
expect(logger.event).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a fetched brand as null when a fresh instrument has no asset_uid', async () => {
|
||||||
|
repository.read.mockResolvedValue(null);
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy' });
|
||||||
|
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||||
|
brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z'),
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats a NOT_FOUND answer as fetched and does not refetch it an hour later', async () => {
|
||||||
|
tbank.getShare.mockRejectedValue(new NotFoundException());
|
||||||
|
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||||
|
sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'),
|
||||||
|
}));
|
||||||
|
repository.read.mockResolvedValue({ ...saved, sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'), assetUid: null, brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
|
||||||
|
tbank.getShare.mockClear();
|
||||||
|
await service.refresh('token', [position], new Date('2026-10-31T13:00:00Z'));
|
||||||
|
expect(tbank.getShare).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records a missing brand and restarts its clock when NOT_FOUND has no saved asset UID', async () => {
|
||||||
|
repository.read.mockResolvedValue(null);
|
||||||
|
tbank.getShare.mockRejectedValue(new NotFoundException());
|
||||||
|
const startedAt = new Date('2026-10-31T12:00:00Z');
|
||||||
|
const result = await service.refresh('token', [position], startedAt);
|
||||||
|
expect(result.values().next().value).toEqual({ sector: null, issuer: null });
|
||||||
|
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
|
||||||
|
brandUid: null, brandName: null, brandFetchedAt: startedAt,
|
||||||
|
}));
|
||||||
|
expect(tbank.getAssetBrand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not leak one refresh call\'s invalid token into another call', async () => {
|
||||||
|
tbank.getShare.mockRejectedValueOnce(new Error(TbankErrorCode.InvalidToken))
|
||||||
|
.mockResolvedValueOnce({ uid: 'uid', figi: 'figi', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
await expect(service.refresh('first', [position], new Date('2026-10-31T12:00:00Z'))).rejects.toThrow();
|
||||||
|
const result = await service.refresh('second', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.values().next().value?.sector).toBe('Energy');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('propagates an invalid token before publishing a classification', async () => {
|
||||||
|
tbank.getShare.mockRejectedValue(new Error(TbankErrorCode.InvalidToken));
|
||||||
|
const result = service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
await expect(result).rejects.toThrow(TbankErrorCode.InvalidToken);
|
||||||
|
expect(repository.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves a fresh brand untouched when the sector refresh finds the same asset', async () => {
|
||||||
|
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(tbank.getAssetBrand).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips unsupported types, zero-quantity positions, and shares a brand lookup across instruments', async () => {
|
||||||
|
repository.read.mockResolvedValue(null);
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
const result = await service.refresh('token', [
|
||||||
|
{ instrumentType: 'currency', instrumentUid: 'cash', figi: 'cash', quantity: { units: 1 } },
|
||||||
|
{ instrumentType: 'share', instrumentUid: 'empty', figi: 'empty', quantity: {} },
|
||||||
|
{ instrumentType: 'Share', instrumentUid: 'upper', figi: 'upper', quantity: { units: 1 } },
|
||||||
|
position,
|
||||||
|
{ instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } },
|
||||||
|
], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.size).toBe(3);
|
||||||
|
expect(tbank.getAssetBrand).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tbank.getEtf).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('skips duplicate instrument keys and retries a failed asset lookup for another instrument in the same walk', async () => {
|
||||||
|
repository.read.mockResolvedValue(null);
|
||||||
|
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' });
|
||||||
|
tbank.getAssetBrand.mockRejectedValueOnce(new Error('upstream unavailable'))
|
||||||
|
.mockResolvedValueOnce({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } });
|
||||||
|
const result = await service.refresh('token', [position, position,
|
||||||
|
{ instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } },
|
||||||
|
], new Date('2026-10-31T12:00:00Z'));
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect(tbank.getShare).toHaveBeenCalledTimes(1);
|
||||||
|
expect(tbank.getAssetBrand).toHaveBeenCalledTimes(2);
|
||||||
|
expect([...result.values()].map(({ issuer }) => issuer)).toEqual([
|
||||||
|
null, { id: 'new-brand', name: 'New brand' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isZero } from '../../../../common/decimal';
|
||||||
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
|
import { metadataKey, TbankClient, type TbankPortfolio } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
import { PositionClassificationRepository } from '../infrastructure/position-classification.repository';
|
||||||
|
|
||||||
|
export type Classification = { issuer: null | { id: string; name: string }; sector: null | string };
|
||||||
|
type Position = NonNullable<TbankPortfolio['positions']>[number];
|
||||||
|
type Instrument = { assetUid?: string; figi?: string; sector?: string; uid?: string };
|
||||||
|
type Row = NonNullable<Awaited<ReturnType<PositionClassificationRepository['read']>>>;
|
||||||
|
type Update = Parameters<PositionClassificationRepository['save']>[1];
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class PositionClassificationService {
|
||||||
|
constructor(
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly repository: PositionClassificationRepository,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async refresh(token: string, positions: Position[], walkStartedAt: Date): Promise<Map<string, Classification>> {
|
||||||
|
const result = new Map<string, Classification>();
|
||||||
|
const inFlight = new Map<string, Promise<unknown>>();
|
||||||
|
for (const position of positions) {
|
||||||
|
if (!['bond', 'etf', 'share'].includes((position.instrumentType ?? '').toLowerCase())) continue;
|
||||||
|
if (isZero(decimal(position.quantity ?? {}))) continue;
|
||||||
|
const key = metadataKey(position);
|
||||||
|
if (result.has(key)) continue;
|
||||||
|
result.set(key, await this.refreshOne(token, position, key, walkStartedAt, inFlight));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshOne(token: string, position: Position, key: string, startedAt: Date,
|
||||||
|
inFlight: Map<string, Promise<unknown>>): Promise<Classification> {
|
||||||
|
let saved: null | Row = null;
|
||||||
|
const update: Update = {};
|
||||||
|
try {
|
||||||
|
saved = await this.repository.read(key);
|
||||||
|
const due = (at: Date | null | undefined) => !at || startedAt.valueOf() - at.valueOf() >= MILLISECONDS_PER_DAY;
|
||||||
|
const sectorDue = due(saved?.sectorFetchedAt);
|
||||||
|
const brandDue = due(saved?.brandFetchedAt);
|
||||||
|
let assetUid = saved?.assetUid ?? null;
|
||||||
|
let instrumentFresh = false;
|
||||||
|
if (sectorDue || (brandDue && !saved?.assetUid)) {
|
||||||
|
const instrument = await this.refreshInstrument(token, position, sectorDue, assetUid, update, startedAt);
|
||||||
|
assetUid = instrument.assetUid;
|
||||||
|
instrumentFresh = instrument.fresh;
|
||||||
|
}
|
||||||
|
const assetChanged = 'assetUid' in update && update.assetUid !== saved?.assetUid;
|
||||||
|
if (assetChanged) {
|
||||||
|
update.brandUid = null;
|
||||||
|
update.brandName = null;
|
||||||
|
update.brandFetchedAt = null;
|
||||||
|
}
|
||||||
|
if (brandDue || assetChanged) {
|
||||||
|
await this.refreshBrand(token, assetUid, update, startedAt, inFlight, instrumentFresh);
|
||||||
|
}
|
||||||
|
if (Object.keys(update).length) saved = await this.repository.save(key, update);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.invalidToken(error)) throw error;
|
||||||
|
this.failed(error);
|
||||||
|
}
|
||||||
|
const current = { ...saved, ...update };
|
||||||
|
return {
|
||||||
|
issuer: current.brandUid && current.brandName ? { id: current.brandUid, name: current.brandName } : null,
|
||||||
|
sector: current.sector ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshInstrument(token: string, position: Position, sectorDue: boolean, savedUid: null | string,
|
||||||
|
update: Update, startedAt: Date): Promise<{ assetUid: null | string; fresh: boolean }> {
|
||||||
|
try {
|
||||||
|
const lookup = await this.instrument(token, position);
|
||||||
|
if (lookup && !this.matches(lookup)) return { assetUid: savedUid, fresh: false };
|
||||||
|
if (lookup) return { assetUid: this.acceptInstrument(lookup.found, savedUid, sectorDue, update, startedAt), fresh: true };
|
||||||
|
if (sectorDue) this.clearSector(update, startedAt);
|
||||||
|
} catch (error) {
|
||||||
|
if (this.invalidToken(error)) throw error;
|
||||||
|
if (error instanceof NotFoundException) {
|
||||||
|
if (sectorDue) this.clearSector(update, startedAt);
|
||||||
|
} else this.failed(error);
|
||||||
|
}
|
||||||
|
return { assetUid: savedUid, fresh: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSector(update: Update, startedAt: Date) {
|
||||||
|
update.sector = null;
|
||||||
|
update.sectorFetchedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private acceptInstrument(found: Instrument, savedUid: null | string, sectorDue: boolean, update: Update, startedAt: Date) {
|
||||||
|
const assetUid = found.assetUid?.trim() || null;
|
||||||
|
if (assetUid !== savedUid) update.assetUid = assetUid;
|
||||||
|
if (sectorDue) this.setSector(update, found.sector, startedAt);
|
||||||
|
return assetUid;
|
||||||
|
}
|
||||||
|
|
||||||
|
private setSector(update: Update, sector: string | undefined, startedAt: Date) {
|
||||||
|
update.sector = sector?.trim() || null;
|
||||||
|
update.sectorFetchedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refreshBrand(token: string, assetUid: null | string, update: Update, startedAt: Date,
|
||||||
|
inFlight: Map<string, Promise<unknown>>, instrumentFresh: boolean) {
|
||||||
|
if (!assetUid) {
|
||||||
|
if (instrumentFresh || 'assetUid' in update || 'sectorFetchedAt' in update) this.clearBrand(update, startedAt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const asset = await this.dedupe(inFlight, assetUid, () => this.tbank.getAssetBrand(token, assetUid));
|
||||||
|
if (asset && asset.assetUid !== assetUid) return;
|
||||||
|
const uid = asset?.brand?.uid?.trim() || null;
|
||||||
|
const name = asset?.brand?.name?.trim() || null;
|
||||||
|
update.brandUid = uid && name ? uid : null;
|
||||||
|
update.brandName = uid && name ? name : null;
|
||||||
|
update.brandFetchedAt = startedAt;
|
||||||
|
} catch (error) {
|
||||||
|
if (this.invalidToken(error)) throw error;
|
||||||
|
if (error instanceof NotFoundException) this.clearBrand(update, startedAt);
|
||||||
|
else this.failed(error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearBrand(update: Update, startedAt: Date) {
|
||||||
|
update.brandUid = null;
|
||||||
|
update.brandName = null;
|
||||||
|
update.brandFetchedAt = startedAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
private matches(lookup: { found: Instrument; id: string; idType: InstrumentIdType }) {
|
||||||
|
return (lookup.idType === InstrumentIdType.Uid ? lookup.found.uid : lookup.found.figi) === lookup.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private invalidToken(error: unknown) {
|
||||||
|
return error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async instrument(token: string, position: Position) {
|
||||||
|
const type = (position.instrumentType ?? '').toLowerCase();
|
||||||
|
const lookup = (id: string, idType: InstrumentIdType) => {
|
||||||
|
switch (type) {
|
||||||
|
case 'bond': return this.tbank.getBond(token, id, idType, true);
|
||||||
|
case 'share': return this.tbank.getShare(token, id, idType, true);
|
||||||
|
default: return this.tbank.getEtf(token, id, idType, true);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const uid = position.instrumentUid?.trim();
|
||||||
|
const figi = position.figi?.trim();
|
||||||
|
if (uid) {
|
||||||
|
try {
|
||||||
|
const found = await lookup(uid, InstrumentIdType.Uid);
|
||||||
|
if (found) return { found, id: uid, idType: InstrumentIdType.Uid };
|
||||||
|
} catch (error) {
|
||||||
|
if (!(error instanceof NotFoundException)) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (figi) {
|
||||||
|
const found = await lookup(figi, InstrumentIdType.Figi);
|
||||||
|
return found ? { found, id: figi, idType: InstrumentIdType.Figi } : null;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private dedupe<T>(map: Map<string, Promise<unknown>>, key: string, run: () => Promise<T>): Promise<T> {
|
||||||
|
const pending = map.get(key);
|
||||||
|
if (pending) return pending as Promise<T>;
|
||||||
|
const promise = run().catch((error: unknown) => {
|
||||||
|
map.delete(key);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
map.set(key, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
|
||||||
|
private failed(error: unknown) {
|
||||||
|
this.logger.event('warn', 'portfolio.classification.failed', { code: error instanceof Error ? error.name : 'Unknown' });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,12 +14,14 @@ const position = (
|
|||||||
isin: null,
|
isin: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
|
sector: null,
|
||||||
|
issuer: null,
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('portfolio structure', () => {
|
describe('portfolio structure', () => {
|
||||||
it('includes cash once and keeps currency instruments among other assets', () => {
|
it('counts cash once and skips CURRENCY positions that mirror it', () => {
|
||||||
const result = portfolioStructure(
|
const result = portfolioStructure(
|
||||||
[
|
[
|
||||||
position('STOCK', '60'),
|
position('STOCK', '60'),
|
||||||
@@ -30,13 +32,13 @@ describe('portfolio structure', () => {
|
|||||||
);
|
);
|
||||||
expect(result.currencies[0]).toEqual({
|
expect(result.currencies[0]).toEqual({
|
||||||
currency: 'RUB',
|
currency: 'RUB',
|
||||||
totalValue: '100',
|
totalValue: '90',
|
||||||
hasNegativeValues: false,
|
hasNegativeValues: false,
|
||||||
categories: [
|
categories: [
|
||||||
{ type: 'STOCK', value: '60', sharePercent: '60' },
|
{ type: 'STOCK', value: '60', sharePercent: '66.666667' },
|
||||||
{ type: 'BOND', value: '20', sharePercent: '20' },
|
{ type: 'BOND', value: '20', sharePercent: '22.222222' },
|
||||||
{ type: 'OTHER', value: '10', sharePercent: '10' },
|
{ type: 'OTHER', value: '0', sharePercent: '0' },
|
||||||
{ type: 'CASH', value: '10', sharePercent: '10' },
|
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ export function portfolioStructure(
|
|||||||
currencies.set(money.currency, group);
|
currencies.set(money.currency, group);
|
||||||
};
|
};
|
||||||
for (const position of positions) {
|
for (const position of positions) {
|
||||||
|
// CURRENCY positions mirror cash, which is counted once as CASH below.
|
||||||
|
if (position.type === 'CURRENCY') continue;
|
||||||
const type = ['BOND', 'STOCK'].includes(position.type)
|
const type = ['BOND', 'STOCK'].includes(position.type)
|
||||||
? position.type
|
? position.type
|
||||||
: 'OTHER';
|
: 'OTHER';
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export class ForecastRepository {
|
|||||||
membershipRevision: number;
|
membershipRevision: number;
|
||||||
portfolioProjection: string;
|
portfolioProjection: string;
|
||||||
portfolios: { accountId: string; dataRevision: number }[];
|
portfolios: { accountId: string; dataRevision: number }[];
|
||||||
|
replacesLatest?: boolean;
|
||||||
revision: string;
|
revision: string;
|
||||||
scopeKey: string;
|
scopeKey: string;
|
||||||
sources: ForecastSourceVersion[];
|
sources: ForecastSourceVersion[];
|
||||||
@@ -51,11 +52,30 @@ export class ForecastRepository {
|
|||||||
const previous = await tx.tbankForecast.findUnique({
|
const previous = await tx.tbankForecast.findUnique({
|
||||||
where: { userId_revision_scopeKey: key },
|
where: { userId_revision_scopeKey: key },
|
||||||
});
|
});
|
||||||
|
// An events read serves the account's latest complete forecast whatever
|
||||||
|
// the requested window; other scopes keep their exact-key row.
|
||||||
|
const requestedWindow = eventsWindow(input.scopeKey);
|
||||||
|
const latest = requestedWindow
|
||||||
|
? await tx.tbankForecast.findFirst({
|
||||||
|
where: {
|
||||||
|
...owner,
|
||||||
|
// scopeKey is `events/[accountId,from,to]` JSON: pin the account.
|
||||||
|
scopeKey: {
|
||||||
|
startsWith: `events/[${JSON.stringify(requestedWindow.accountId)},`,
|
||||||
|
},
|
||||||
|
membershipRevision: input.membershipRevision,
|
||||||
|
},
|
||||||
|
orderBy: [{ calculatedAt: 'desc' }, { scopeKey: 'asc' }],
|
||||||
|
})
|
||||||
|
: previous;
|
||||||
const fallback =
|
const fallback =
|
||||||
previous?.membershipRevision === input.membershipRevision
|
latest?.membershipRevision === input.membershipRevision ? latest : null;
|
||||||
? previous
|
|
||||||
: null;
|
|
||||||
if (!input.candidate) return fallback;
|
if (!input.candidate) return fallback;
|
||||||
|
// Only the default window may replace the latest forecast; an explicit
|
||||||
|
// range is served from it and publishes only when nothing is saved.
|
||||||
|
if (!input.replacesLatest && fallback && fallback.scopeKey !== input.scopeKey) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
const portfolios = await tx.tbankPortfolio.findMany({
|
const portfolios = await tx.tbankPortfolio.findMany({
|
||||||
where: {
|
where: {
|
||||||
...owner,
|
...owner,
|
||||||
@@ -131,7 +151,7 @@ export class ForecastRepository {
|
|||||||
) as Prisma.InputJsonObject;
|
) as Prisma.InputJsonObject;
|
||||||
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
||||||
if (
|
if (
|
||||||
fallback &&
|
fallback?.scopeKey === input.scopeKey &&
|
||||||
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
|
||||||
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
|
||||||
) {
|
) {
|
||||||
@@ -154,3 +174,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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user