55 Commits
Author SHA1 Message Date
ksv741 0a0e70b273 chore(release): 2.3.0
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 6m23s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend image / publish (push) Failing after 6m34s
Backend verify / verify (push) Successful in 6m36s
2026-10-24 19:15:04 +03:00
ksv741 449cfcd6c5 chore(deps): update non-major dependencies #11
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 5m31s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m51s
2026-10-24 18:20:20 +03:00
codex-bot 966a1f793a feat: log unhandled errors and pretty dev output (WealthLedger/backend#120) (#126)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m39s
feat: log unhandled errors and pretty dev output (#120)
2026-10-24 15:06:34 +03:00
codex-bot d1f6969dc7 fix: persist refresh cookie across browser restarts (WealthLedger/backend#123)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m58s
2026-10-23 08:34:39 +03:00
ksv741 b20080ac85 chore(release): 2.2.0
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m38s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend image / publish (push) Successful in 7m58s
Backend verify / verify (push) Successful in 5m40s
2026-10-18 16:25:22 +03:00
codex-bot 32622c1dad test: cover auth token, cookie and expiry boundaries (WealthLedger/backend#61) (#113)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m26s
2026-10-18 15:55:05 +03:00
codex-bot 4f485ab0e9 chore: adopt linear git model in backend docs (#111)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 30s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m44s
2026-10-17 14:19:19 +03:00
codex-bot 8a6f60d965 fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) (#110)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
2026-10-16 23:23:12 +03:00
codex-bot e03fad622b fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
2026-10-16 10:00:47 +03:00
ksv741 774e58ef80 Merge branch 'chore/30-sync-release' into develop
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 28s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 5m38s
2026-10-16 07:38:58 +03:00
codex-bot 702302126b test: await portfolio publication done instead of polling ticks (WealthLedger/backend#63) (#103)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 5m25s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m39s
2026-10-15 15:40:03 +03:00
codex-bot ec6d9a2639 test: replace metadata sleeps with upstream start barriers (WealthLedger/backend#63) (#102)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 15s
Backend verify / verify (push) Failing after 6m46s
2026-10-15 14:46:09 +03:00
codex-bot 7bc7d8ddec test: replace late portfolio sleep with started and done barriers (WealthLedger/backend#63) (#101)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m21s
2026-10-15 12:23:14 +03:00
codex-bot 9e4f9d9cab test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65) (#100)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m59s
2026-10-15 11:50:47 +03:00
codex-bot a93f2d55e7 chore: make OpenAPI export atomic and add read-only check (WealthLedger/backend#3) (#97)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m4s
2026-10-15 11:24:38 +03:00
codex-bot 96d4de4195 refactor: remove unreachable catalog producer and sync orchestration (WealthLedger/backend#62) (#98)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m47s
2026-10-15 10:46:58 +03:00
codex-bot c789e33c41 chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m22s
2026-10-15 06:15:47 +03:00
codex-bot 2ad3517d87 fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m50s
2026-10-15 06:00:39 +03:00
codex-bot 0d7839cf79 fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Successful in 5m20s
2026-10-15 05:41:21 +03:00
codex-bot 6975853656 fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m56s
2026-10-14 22:43:08 +03:00
codex-bot 14bcb9da5f fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 7m31s
2026-10-14 18:00:20 +03:00
codex-bot b90cedd580 chore: unify verification preparation (WealthLedger/backend#66)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m44s
2026-10-14 17:01:53 +03:00
codex-bot da2e1d0781 perf: start bond search prices with details (WealthLedger/backend#71) (#89)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 6m48s
2026-10-14 14:04:07 +03:00
codex-bot bbb5afff84 ci: validate edited PR titles (WealthLedger/backend#74)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m37s
Closes #74
2026-10-14 09:02:53 +03:00
codex-bot 285dd08472 perf: batch bank operation page relations (WealthLedger/backend#68)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m20s
Closes #68
2026-10-14 08:48:37 +03:00
codex-bot 1033d2114e test: slim sqlite runner regression workload (WealthLedger/backend#72)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m47s
Closes #72
2026-10-14 08:34:37 +03:00
codex-bot 203cc72cb2 chore: align SQLite driver version (WealthLedger/backend#67)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m18s
Closes #67
2026-10-14 08:12:51 +03:00
codex-bot 9b7e5e1215 refactor: extract shared public instrument projection (#69) (#84)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 18s
Backend verify / verify (push) Failing after 6m47s
2026-10-13 21:54:51 +03:00
codex-bot 31b0e15579 feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 7m1s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:46 +03:00
ksv741 3d50db7903 ci: validate release PR commits independently (WealthLedger/main#30)
Backend verify / commits (push) Skipped
Backend image / publish (push) Successful in 7m28s
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Successful in 5m3s
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m11s
2026-10-12 23:39:30 +03:00
ksv741 8d5569a3c7 chore(release): 2.1.0
Backend verify / commits (pull_request) Failing after 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
2026-10-12 23:19:12 +03:00
ksv741 90486d4237 Merge pull request 'fix: bound hourly step awaits by the per-step wait limit (#56)' (#78) from fix/56-hourly-step-await-bound into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m0s
Reviewed-on: #78
2026-10-12 22:25:27 +03:00
ksv741 52fa3a36ba fix: bound hourly step awaits by the per-step wait limit (#56)
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m45s
2026-10-12 22:17:53 +03:00
ksv741 b9b76bab91 Merge pull request 'fix: restore terminal hourly lifecycle after persistence failure (#56)' (#77) from fix/56-harden-hourly-sync-lifecycle into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 7m10s
Reviewed-on: #77
Reviewed-by: hermes-agent <hermes@ksv741.ru>
2026-10-12 21:59:27 +03:00
ksv741 fa57a201fd fix: restore terminal hourly lifecycle after persistence failure (#56)
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 5m48s
2026-10-12 20:42:19 +03:00
codex-bot 96a442253e Merge pull request 'chore: align backend engineering workflow (WealthLedger/main#27)' (#76) from chore/27-engineering-workflow into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m24s
2026-10-12 11:00:36 +03:00
ksv741 c5248dbefc docs: pin backend workflow reference (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 5m3s
2026-10-12 10:22:13 +03:00
ksv741 c7d060023b fix: align backend workflow rules (WealthLedger/main#27)
Backend verify / commits (pull_request) Canceled after 0s
Backend verify / security (pull_request) Canceled after 0s
Backend verify / verify (pull_request) Canceled after 0s
2026-10-12 10:20:50 +03:00
ksv741 a31e8d2c4d chore: align backend engineering workflow (WealthLedger/main#27)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m56s
2026-10-12 09:20:31 +03:00
codex-bot 2f7c15205f Merge pull request 'docs: clarify backend verification policy' (#75) from codex/issue-24-process into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m9s
2026-10-11 23:23:10 +03:00
ksv741 d7895f287e docs: clarify backend verification policy
Backend verify / commits (pull_request) Successful in 34s
Backend verify / security (pull_request) Successful in 16s
Backend verify / verify (pull_request) Successful in 5m15s
2026-10-11 23:06:51 +03:00
codex-bot bcd5cb3dd0 feat: add stock screener
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m33s
2026-10-11 11:45:09 +03:00
ksv741 0ad84ff4f0 ci: skip checks on PR metadata edits
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m38s
2026-10-11 11:36:51 +03:00
ksv741 7d10ef8adf feat: add stock screener
Backend verify / commits (pull_request) Successful in 28s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m45s
2026-10-11 11:18:21 +03:00
ksv741 bc631e72de test: align resource availability metadata
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m21s
2026-10-09 23:10:50 +03:00
ksv741 947ffefa9b fix: keep candle retries read-only 2026-10-09 23:10:50 +03:00
ksv741 db375cbee7 test: align all accounts metadata 2026-10-09 23:10:50 +03:00
ksv741 a46a4112cb chore: prune obsolete eslint suppressions 2026-10-09 23:10:50 +03:00
ksv741 966b2a5836 test: align upcoming events with local reads 2026-10-09 23:10:50 +03:00
ksv741 323b83b582 feat: reconcile hourly investment resource states 2026-10-09 23:10:50 +03:00
codex-bot 5d857de7a5 Merge pull request 'feat: add hourly portfolio sync history' (#49) from release/2.0.0 into develop
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m46s
Merge hourly portfolio sync history backend when checks pass
2026-10-09 09:57:20 +03:00
ksv741 2facd8675c test: increase ledger request timeout
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 5m21s
2026-10-09 09:50:59 +03:00
ksv741 4870d49503 test: extend ledger sqlite timeout
Backend verify / commits (pull_request) Successful in 32s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Failing after 4m32s
2026-10-09 09:43:41 +03:00
ksv741 85858a160f fix: unblock backend dependency audit
Backend verify / commits (pull_request) Successful in 36s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Failing after 4m47s
2026-10-09 09:14:28 +03:00
ksv741 0057925888 feat: add hourly portfolio sync history
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Failing after 12s
Backend verify / verify (pull_request) Skipped
2026-10-09 09:04:41 +03:00
121 changed files with 6811 additions and 4552 deletions
+35 -13
View File
@@ -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, раздел «Релизы».
+12
View File
@@ -0,0 +1,12 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
+10
View File
@@ -0,0 +1,10 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
+12
View File
@@ -0,0 +1,12 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
+12
View File
@@ -0,0 +1,12 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
+11
View File
@@ -0,0 +1,11 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+1 -2
View File
@@ -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 }}
+10 -6
View File
@@ -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
@@ -62,12 +67,13 @@ jobs:
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: | run: |
npm audit --omit=dev --audit-level=low npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security] needs: [commits, security]
if: >- if: >-
!cancelled() && needs.security.result == 'success' && !cancelled() && gitea.event.action != 'edited' &&
needs.security.result == 'success' &&
gitea.event_name != 'schedule' gitea.event_name != 'schedule'
timeout-minutes: 45 timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -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.
+1 -1
View File
@@ -1 +1 @@
24.21.0 24.22.1
+3
View File
@@ -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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+98
View File
@@ -1,5 +1,103 @@
# Changelog # Changelog
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
### Features
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
### Bug Fixes
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
### Maintenance
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
### Features
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
### Bug Fixes
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
### Performance
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
### Refactoring
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
### CI
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
### Tests
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
### Maintenance
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07) ## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+3 -2
View File
@@ -1,4 +1,4 @@
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
@@ -19,7 +19,8 @@ EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]
FROM dependencies AS build FROM dependencies AS build
RUN npm run build && npm prune --omit=dev --omit=optional # The client is already generated at the shared dependencies stage (same revision).
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
FROM base AS production FROM base AS production
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
+58 -24
View File
@@ -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 не включены.
+9 -1
View File
@@ -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'],
},
}; };
+2 -34
View File
@@ -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,11 +91,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/investment-resource.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 3
}
},
"src/modules/investments/investment-sync-plan.ts": { "src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -149,30 +133,14 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": { "src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
} }
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": { "src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 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 +166,4 @@
"count": 3 "count": 3
} }
} }
} }
+1 -1
View File
@@ -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'],
+1057 -1328
View File
File diff suppressed because it is too large Load Diff
+54 -45
View File
@@ -1,20 +1,21 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.0.0", "version": "2.3.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");
+29
View File
@@ -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
+40 -10
View File
@@ -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;
});
+55 -2
View File
@@ -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;
+5
View File
@@ -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',
]); ]);
@@ -12,7 +12,6 @@ import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -320,11 +319,7 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
@@ -121,6 +121,23 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
async online<T>(
userId: string,
run: (connection: { revision: string; token: string }) => Promise<T>,
): Promise<T> {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
const token = this.tokens.decrypt(connection.encryptedToken);
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -192,6 +209,17 @@ export class TbankAccessService {
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT }, membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
}, },
}); });
// Value history visibility follows the confirmed membership only; a
// failed fetch never reaches this transaction and changes nothing.
const supportedIds = supported.map(({ id }) => id);
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: supportedIds } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
data: { deletedAt: fetchedAt },
});
if (completeWork) { if (completeWork) {
await tx.tbankSyncWork.update({ await tx.tbankSyncWork.update({
where: { id: attempt.id }, where: { id: attempt.id },
@@ -8,7 +8,9 @@ import type {
export type TbankShare = TbankInstrument & { export type TbankShare = TbankInstrument & {
apiTradeAvailableFlag?: boolean; apiTradeAvailableFlag?: boolean;
assetUid?: string; assetUid?: string;
buyAvailableFlag?: boolean;
classCode?: string; classCode?: string;
countryOfRisk?: string;
currency?: string; currency?: string;
figi?: string; figi?: string;
first_1dayCandleDate?: TbankTimestamp; first_1dayCandleDate?: TbankTimestamp;
+4
View File
@@ -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(
+204 -1
View File
@@ -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', () => {
@@ -282,3 +347,141 @@ describe('TbankClient metadata retries', () => {
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
}); });
describe('TbankClient share catalog', () => {
const token = 'token';
const catalogSize = 40;
let client: TbankClient;
let call: jest.Mock;
let logger: {
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
};
beforeEach(() => {
call = jest.fn();
logger = {
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
};
client = Object.assign(
Object.create(TbankClient.prototype) as TbankClient,
{ call, logger },
);
});
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
call.mockResolvedValue({ instruments });
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
});
});
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
const catalog = Array.from({ length: catalogSize }, (_, index) => {
const fitting = index === catalogSize - 1;
return {
apiTradeAvailableFlag: fitting,
buyAvailableFlag: fitting,
ticker: fitting ? 'FIT' : `T${index}`,
uid: fitting ? 'uid-fit' : `uid-${index}`,
};
});
call.mockResolvedValue({ instruments: catalog });
const shares = await client.getAllShares(token);
expect(shares).toEqual(catalog);
expect(shares).toHaveLength(catalogSize);
expect(shares).toContainEqual({
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
ticker: 'FIT',
uid: 'uid-fit',
});
});
it('rejects when the source refuses instead of returning an empty success', async () => {
const error = new ServiceUnavailableException({
code: 'TBANK_UNAVAILABLE',
});
call.mockRejectedValue(error);
await expect(client.getAllShares(token)).rejects.toBe(error);
});
it('rejects when the response carries no instrument list', async () => {
call.mockResolvedValue({});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an empty instrument list with the public unavailable code', async () => {
call.mockResolvedValue({ instruments: [] });
const failure = await client
.getAllShares(token)
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(ServiceUnavailableException);
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
code: 'TBANK_UNAVAILABLE',
});
});
it('rejects a non-list instrument payload as uninterpretable', async () => {
call.mockResolvedValue({ instruments: 'corrupted' });
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('rejects an uninterpretable entry without instrument identity', async () => {
call.mockResolvedValue({
instruments: [
{ ticker: 'FIT', uid: 'uid-fit' },
{ ticker: 'BROKEN' },
],
});
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
call.mockResolvedValue({
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
});
await client.getAllShares(token);
expect(logger.event).toHaveBeenCalledWith(
'info',
'tbank.shares.catalog.fetched',
expect.objectContaining({ count: 2, operation: 'shares' }),
);
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
});
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
call.mockResolvedValue({ instruments: [] });
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
expect(logger.event).toHaveBeenCalledWith(
'error',
'tbank.shares.catalog.unusable',
expect.objectContaining({ operation: 'shares' }),
);
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
'secret-token',
);
});
});
+78 -5
View File
@@ -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,
@@ -481,15 +519,23 @@ export class TbankClient {
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 +553,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 +563,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),
@@ -543,17 +591,42 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
diagnostics?: RpcDiagnostics,
): Promise<Response> { ): Promise<Response> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); metadata.set('Authorization', `Bearer ${token}`);
// A channel state is a snapshot, not proof that this particular call reached the server.
const channel = diagnostics && client.getChannel();
if (diagnostics) diagnostics.headersReceived = false;
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
client[method]( const call = client[method](
request as never, request as never,
metadata, metadata,
{ deadline: new Date(Date.now() + requestDeadlineMs) }, { deadline: new Date(Date.now() + requestDeadlineMs) },
(error, response) => (error ? reject(error) : resolve(response)), (error, response) => {
if (diagnostics && channel) {
diagnostics.channelState = connectivityState[
channel.getConnectivityState(false)
];
}
if (error) reject(error);
else resolve(response);
},
); );
if (diagnostics) {
call.on('metadata', (headers: Metadata) => {
diagnostics.headersReceived = true;
const trackingId = headers.get('x-tracking-id')[0];
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
if (
typeof trackingId === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
) {
diagnostics.trackingId = trackingId;
}
});
}
}); });
} }
} }
+1 -1
View File
@@ -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();
} }
+1 -1
View File
@@ -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,
}; };
} }
} }
+28 -10
View File
@@ -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,
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
@Get() @Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto }) @ApiOkResponse({ type: BrokerageAccountsResourceDto })
list( list(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.accounts.list(request.user.sub);
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
} }
} }
@@ -3,11 +3,12 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, PortfolioModule], imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [BrokerageAccountsController], controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService], providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService], exports: [BrokerageAccountsService],
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => { describe('saved brokerage account projection', () => {
const portfolio = { saved: jest.fn() }; const portfolio = { saved: jest.fn() };
const service = new BrokerageAccountsService(portfolio as never); const sync = { hourly: jest.fn().mockResolvedValue(null) };
const service = new BrokerageAccountsService(portfolio as never, sync as never);
it('distinguishes unknown membership from a confirmed empty list', async () => { it('distinguishes unknown membership from a confirmed empty list', async () => {
portfolio.saved.mockResolvedValueOnce({ portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' }, membership: { ...meta, completeness: 'incomplete' },
accounts: [], accounts: [],
}); });
expect((await service.list('owner', true)).data).toBeNull(); expect((await service.list('owner')).data).toBeNull();
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
expect((await service.list('owner', true)).data).toEqual([]); expect((await service.list('owner')).data).toEqual([]);
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
@@ -58,13 +59,46 @@ describe('saved brokerage account projection', () => {
}, },
], ],
}); });
const response = await service.list('owner', true); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('partial'); expect(response.meta.availability).toBe('available');
expect(response.meta.completeness).toBe('complete');
});
it('observes the latest hourly walk without starting another one', async () => {
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
sync.hourly.mockResolvedValue({
startedAt: new Date('2026-09-25T12:00:00Z'),
finishedAt: new Date('2026-09-25T12:01:00Z'),
state: 'partial',
sections: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
const response = await service.list('owner');
expect(response.sync).toEqual({
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
state: 'partial',
problems: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
expect(sync.hourly).toHaveBeenCalledWith('owner');
}); });
}); });
@@ -3,16 +3,20 @@ import { createHash } from 'node:crypto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
constructor(private readonly portfolio: PortfolioService) {} constructor(
private readonly portfolio: PortfolioService,
private readonly sync: TbankSyncRepository,
) {}
async list(userId: string, observe = false) { async list(userId: string): Promise<BrokerageAccountsResourceDto> {
const saved = await this.portfolio.saved(userId, 'all', observe); const saved = await this.portfolio.saved(userId, 'all');
const data: BrokerageAccountDto[] | null = const data: BrokerageAccountDto[] | null =
saved.membership.availability === 'missing' saved.membership.completeness === 'incomplete'
? null ? null
: saved.accounts.map((account) => ({ : saved.accounts.map((account) => ({
id: account.id, id: account.id,
@@ -31,6 +35,8 @@ export class BrokerageAccountsService {
} }
: null, : null,
})); }));
// The read-only result of the last hourly walk; never starts one.
const hourly = await this.sync.hourly(userId);
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -49,18 +55,26 @@ export class BrokerageAccountsService {
availability: availability:
data === null data === null
? 'missing' ? 'missing'
: saved.accounts.some((account) => !account.snapshot) : 'complete',
? 'partial'
: 'complete',
fetchedAt: saved.membership.lastSuccessfulAt fetchedAt: saved.membership.lastSuccessfulAt
? new Date(saved.membership.lastSuccessfulAt) ? new Date(saved.membership.lastSuccessfulAt)
: null, : null,
freshForMs: 300_000, freshForMs: 3_600_000,
work: saved.membership.sync.id work: null,
? { ...saved.membership.sync, id: saved.membership.sync.id } lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
: null,
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly
? {
state: hourly.state,
startedAt: hourly.startedAt.toISOString(),
finishedAt: hourly.finishedAt?.toISOString() ?? null,
problems: hourly.sections.map((section) => ({
...section,
accountId: section.accountId ?? null,
})),
}
: null,
}; };
} }
} }
@@ -67,28 +67,6 @@ export class IncomeSummaryDto {
declare calculatedAt: string; declare calculatedAt: string;
} }
export class IncomeComparisonCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare difference: IncomeCurrencySummaryDto;
}
export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
}
export class IncomeBucketDto { export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
} }
export class IncomeAnalyticsDto extends IncomeSummaryDto { export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare previous: IncomeComparisonDto | null;
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'month' | 'week'; declare bucketGranularity: 'day' | 'month' | 'week';
@@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto {
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const; const incomeDependencyRoles = ['selected'] as const;
export class IncomeDependencyDto extends InvestmentDependencyDto { export class IncomeDependencyDto extends InvestmentDependencyDto {
@ApiProperty({ enum: incomeDependencyRoles }) @ApiProperty({ enum: incomeDependencyRoles })
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('labels each comparison dependency independently of its resource key', async () => { it('labels the selected dependency independently of its resource key', async () => {
seed([]); seed([]);
const response = await service.analytics( const response = await service.analytics(
'user', 'user',
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
); );
expect(response.meta.dependencies).toEqual([ expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
]); ]);
}); expect(
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
it('labels an unavailable leap-day comparison as year', async () => { ).toBe(false);
operations.collectRanges.mockImplementation(
(_user: string, _account: string, build: () => unknown[]) =>
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
);
const response = await service.analytics(
'user',
'account',
'2024-02-29',
'2024-02-29',
);
expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected' }),
expect.objectContaining({ role: 'previous' }),
expect.objectContaining({
role: 'year',
availability: 'missing',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}),
]);
expect(response.data?.yearComparison).toBeNull();
}); });
function seed(items: BrokerageOperationDto[]) { function seed(items: BrokerageOperationDto[]) {
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
build: (opened?: Date) => unknown[], build: (opened?: Date) => unknown[],
) => { ) => {
build(undefined); build(undefined);
return [items, [], []].map((items, index) => ({ return [items].map((items, index) => ({
items, items,
meta: completeMeta(index), meta: completeMeta(index),
})); }));
@@ -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', () => {
@@ -14,45 +14,28 @@ const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1; const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0; const PREVIOUS_MONTH_LAST_DAY = 0;
const PREVIOUS_DAY_OFFSET = -1;
const PREVIOUS_YEAR_OFFSET = 1;
const DATE_COMPONENT_DIGITS = 2;
const DAILY_BUCKET_MAX_DAYS = 45; const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180; const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7; const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
openedDate?: Date,
): { ): {
granularity: IncomeBucketGranularity; granularity: IncomeBucketGranularity;
previous: DateOnlyRange;
selected: DateOnlyRange; selected: DateOnlyRange;
yearAgo: DateOnlyRange | null;
} { } {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date()); const today = moscowDate(new Date());
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return { return {
selected, selected,
previous,
yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day' ? 'day'
@@ -136,34 +119,6 @@ function endOfMonth(value: string): string {
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function isFullMonth(range: DateOnlyRange): boolean {
return (
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
);
}
function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
return { from: firstOfMonth(last), to: last };
}
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
return { from: addDays(range.from, days), to: addDays(range.to, days) };
}
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
return from && to ? { from, to } : null;
}
function invalidPeriod(): BadRequestException { function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
} }
@@ -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(
@@ -154,7 +155,7 @@ export class CatalogEnrichmentService {
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
availability: row ? 'complete' : 'missing', availability: row ? 'complete' : 'missing',
fetchedAt: row?.fetchedAt ?? null, fetchedAt: row?.fetchedAt ?? null,
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)], freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
work: byScope.get(catalogScope(input)) ?? null, work: byScope.get(catalogScope(input)) ?? null,
}), }),
}; };
@@ -186,14 +187,10 @@ export class CatalogEnrichmentService {
(entry) => (entry) =>
entry.instrumentUid === input.uid && entry.kind === input.kind, entry.instrumentUid === input.uid && entry.kind === input.kind,
); );
if ( const policy = sourcePolicy(input.kind);
!tbankSourceNeedsRefresh( const needsRefresh = force || !row?.fetchedAt ||
sourcePolicy(input.kind), (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
row?.fetchedAt ?? null, if (!needsRefresh) {
new Date(),
force,
)
) {
continue; continue;
} }
const reserved = await this.repository.reserve( const reserved = await this.repository.reserve(
@@ -212,7 +209,7 @@ export class CatalogEnrichmentService {
const done = new Promise<void>((finish) => { const done = new Promise<void>((finish) => {
resolve = finish; resolve = finish;
}); });
this.completions.set(attemptId, { workId: id, done, resolve }); this.completions.set(attemptId, { attempt, done, resolve, workId: id });
const key = producerKey(input); const key = producerKey(input);
const producer = this.producers.get(key); const producer = this.producers.get(key);
if (producer) producer.observers.push({ attempt, input }); if (producer) producer.observers.push({ attempt, input });
@@ -241,6 +238,36 @@ export class CatalogEnrichmentService {
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
if (section === 'company' && !observe) {
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid
? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid),
])
: [null, null];
return { description, fundamentals, instrument };
});
const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`,
availability: 'complete',
fetchedAt: new Date(),
freshForMs: Number.MAX_SAFE_INTEGER,
work: null,
});
return {
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
meta,
unavailable: [],
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
};
}
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] =
section === 'company' section === 'company'
? ['detail', 'description', 'fundamentals'] ? ['detail', 'description', 'fundamentals']
@@ -263,7 +290,7 @@ export class CatalogEnrichmentService {
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
if (first.meta.availability === 'complete' && !first.instrument) { if (first.meta.completeness === 'complete' && !first.instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const values = sources.reduce<CatalogPayload>( const values = sources.reduce<CatalogPayload>(
@@ -272,7 +299,7 @@ export class CatalogEnrichmentService {
); );
const dependencies = sources.map((source) => source.meta); const dependencies = sources.map((source) => source.meta);
const complete = dependencies.every( const complete = dependencies.every(
(dependency) => dependency.availability === 'complete', (dependency) => dependency.completeness === 'complete',
); );
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/${section}/${uid}`, resourceKey: `${instrumentType}/${section}/${uid}`,
@@ -287,10 +314,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt) ? new Date(first.meta.lastSuccessfulAt)
: null, : null,
freshForMs: freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
section === 'company'
? tbankSourceTtlMs.company
: tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -300,7 +324,7 @@ export class CatalogEnrichmentService {
unavailable: sources unavailable: sources
.filter( .filter(
(source) => (source) =>
source.meta.availability !== 'complete' || source.meta.completeness !== 'complete' ||
source.meta.sync.state === 'failed', source.meta.sync.state === 'failed',
) )
.map((source) => source.input.kind), .map((source) => source.input.kind),
@@ -308,6 +332,7 @@ export class CatalogEnrichmentService {
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
private async load( private async load(
input: CatalogSource, input: CatalogSource,
token: string, token: string,
@@ -323,7 +348,7 @@ export class CatalogEnrichmentService {
} }
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence. // eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response'); if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument(instrument) : null }; return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
} }
if (input.kind === 'trading-status') { if (input.kind === 'trading-status') {
return { return {
@@ -470,6 +495,7 @@ export class CatalogEnrichmentService {
throw new Error('Unsupported catalog source'); throw new Error('Unsupported catalog source');
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
private async drain() { private async drain() {
try { try {
while (this.queue.length) { while (this.queue.length) {
@@ -556,6 +582,14 @@ export class CatalogEnrichmentService {
} }
} }
} catch { } catch {
// Persistence failed mid-batch: finish every still-open observer
// attempt durably so no source row is left running. Best effort — a
// row that stays open is re-reserved by the next reader.
for (const completion of this.completions.values()) {
await this.repository
.finish(completion.attempt, false)
.catch(() => undefined);
}
for (const completion of this.completions.values()) completion.resolve(); for (const completion of this.completions.values()) completion.resolve();
this.completions.clear(); this.completions.clear();
this.active.clear(); this.active.clear();
@@ -600,39 +634,6 @@ function sourceInputs(
})), })),
); );
} }
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: (value as TbankShare).shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
const previousCloseLookbackDays = 20; const previousCloseLookbackDays = 20;
const previousCloseLookbackMs = const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY; previousCloseLookbackDays * MILLISECONDS_PER_DAY;
@@ -651,6 +652,7 @@ export type CatalogPayload = {
price?: null | TbankLastPrice; price?: null | TbankLastPrice;
tradingStatus?: null | string; tradingStatus?: null | string;
}; };
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
@@ -663,9 +665,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) { ) {
return 'quote'; return 'quote';
} }
if (kind === 'description' || kind === 'fundamentals') return 'company'; return 'stable';
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
return 'metadata';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
return input.instrumentType === 'bond' && return input.instrumentType === 'bond' &&
@@ -0,0 +1,64 @@
import type {
TbankBond,
TbankShare,
} from '../../../../integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument';
const share = {
uid: 'share-uid',
figi: 'FIGI1',
assetUid: 'asset-1',
name: 'Share',
ticker: 'SHAR',
isin: 'US000',
classCode: 'TQBR',
realExchange: 'REAL_EXCHANGE_MOEX',
apiTradeAvailableFlag: true,
currency: 'rub',
shareType: 'SHARE_TYPE_COMMON',
sector: 'Finance',
lot: 1,
brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' },
first_1dayCandleDate: '2020-01-01T00:00:00Z',
privateField: 'must-not-leak',
} as unknown as TbankShare;
const bond = {
uid: 'bond-uid',
figi: 'FIGI2',
assetUid: 'asset-2',
name: 'Bond',
ticker: 'BOND',
classCode: 'TQOB',
currency: 'rub',
lot: 1,
nominal: { value: '1000', currency: 'rub' },
initialNominal: { value: '1000', currency: 'rub' },
aciValue: { value: '10', currency: 'rub' },
maturityDate: '2030-01-01T00:00:00Z',
couponQuantityPerYear: 2,
bondType: 'BOND_TYPE_EXPIRING',
brand: null,
privateField: 'must-not-leak',
} as unknown as TbankBond;
describe('publicInstrument projection', () => {
it('keeps approved share fields, trims brand, drops private extras', () => {
const result = publicInstrument(share);
expect(result.uid).toBe('share-uid');
expect(result.shareType).toBe('SHARE_TYPE_COMMON');
expect(result.brand).toEqual({ logoName: 'logo.png' });
expect(result).not.toHaveProperty('privateField');
expect(result).not.toHaveProperty('brand.title');
});
it('projects bonds with missing optional fields and null brand', () => {
const result = publicInstrument(bond);
expect(result.nominal).toEqual({ value: '1000', currency: 'rub' });
expect(result.shareType).toBeUndefined();
expect(result.brand).toBeUndefined();
expect(result.isin).toBeUndefined();
expect(result).not.toHaveProperty('privateField');
});
});
@@ -0,0 +1,38 @@
import type {
TbankBond,
TbankShare,
} from '../../../../integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -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')
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockCatalogService } from './stocks/application/stock-catalog.service';
import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service'; import { StockMarketService } from './stocks/application/stock-market.service';
import { StockScreenerService } from './stocks/application/stock-screener.service';
import { StockController } from './stocks/presentation/stock.controller'; import { StockController } from './stocks/presentation/stock.controller';
@Module({ @Module({
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
controllers: [StockController, BondController], controllers: [StockController, BondController],
exports: [StockIncomeService, BondIncomeService], exports: [StockIncomeService, BondIncomeService],
providers: [ providers: [
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
BondIncomeService, BondIncomeService,
BondMarketService, BondMarketService,
GetStockCompanyUseCase, GetStockCompanyUseCase,
StockIncomeService, StockIncomeService,
StockMarketService, StockMarketService,
StockCatalogService, StockCatalogService,
StockScreenerService,
BondCatalogService, BondCatalogService,
], ],
}) })
@@ -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' },
}; };
} }
} }
@@ -0,0 +1,108 @@
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { StockScreenerService } from './stock-screener.service';
const token = 'secret-token';
const revision = 'revision-1';
const pageSize = 30;
function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
return {
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
currency: 'RUB',
lot: 1,
name: `Share ${index}`,
ticker: `T${String(index).padStart(2, '0')}`,
uid: `e6123145-9665-43e0-8413-cd61b8aa${String(index).padStart(4, '0')}`,
...overrides,
};
}
describe('StockScreenerService', () => {
const access = { online: jest.fn(), local: jest.fn() };
const client = { getAllShares: jest.fn() };
const logger = { event: jest.fn() };
let service: StockScreenerService;
beforeEach(() => {
jest.clearAllMocks();
access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise<unknown>) =>
run({ revision, token }),
);
access.local.mockResolvedValue({ revision });
client.getAllShares.mockResolvedValue(
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
);
service = new StockScreenerService(access, client as never, logger as never);
});
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
const first = await service.search('user-1', {});
const second = await service.page('user-1', first.searchId, 2);
expect(first.items).toHaveLength(pageSize);
expect(first.hasNext).toBe(true);
expect(second).toMatchObject({ asOf: first.asOf, hasNext: false, page: 2, searchId: first.searchId });
expect(second.items).toEqual([expect.objectContaining({ ticker: 'T30' })]);
expect(client.getAllShares).toHaveBeenCalledTimes(1);
});
it('creates a fresh result for every search and preserves a successful empty result', async () => {
client.getAllShares.mockResolvedValueOnce([share(1, { sector: 'energy' })]).mockResolvedValueOnce([share(2, { sector: 'energy' })]);
const first = await service.search('user-1', { sector: 'finance' });
const second = await service.search('user-1', { sector: 'finance' });
expect(first).toMatchObject({ hasNext: false, items: [], page: 1 });
expect(second).toMatchObject({ hasNext: false, items: [], page: 1 });
expect(second.searchId).not.toBe(first.searchId);
expect(client.getAllShares).toHaveBeenCalledTimes(2);
});
it('does not store a result when the catalog source fails', async () => {
client.getAllShares.mockRejectedValueOnce(new ServiceUnavailableException());
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('denies missing, foreign, stale, and out-of-range pages without fetching', async () => {
const first = await service.search('user-1', {});
access.local.mockResolvedValueOnce({ revision: 'revision-2' });
await expect(service.page('user-1', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-2', first.searchId, 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-1', 'missing', 1)).rejects.toBeInstanceOf(NotFoundException);
await expect(service.page('user-1', first.searchId, 3)).rejects.toMatchObject({ status: 400 });
expect(client.getAllShares).toHaveBeenCalledTimes(1);
});
it('null-normalizes optional source fields and rejects malformed required fields', async () => {
client.getAllShares.mockResolvedValueOnce([
share(1, {
countryOfRisk: undefined,
currency: undefined,
isin: undefined,
realExchange: undefined,
sector: undefined,
shareType: undefined,
ticker: undefined,
}),
]);
const result = await service.search('user-1', {});
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
});
it('preserves the catalog logo name in screener results', async () => {
client.getAllShares.mockResolvedValueOnce([share(1, { brand: { logoName: 'sber.png' } })]);
const result = await service.search('user-1', {});
expect(result.items).toEqual([expect.objectContaining({ logoName: 'sber.png' })]);
});
});
@@ -0,0 +1,142 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import {
selectStockScreenerInstruments,
type StockScreenerConditions,
} from '../domain/stock-screener-selection.rules';
const MAX_SNAPSHOTS = 100;
const PAGE_SIZE = 30;
const MIN_PAGE = 1;
const MINIMUM_SNAPSHOT_COUNT = 1;
const SECONDS_PER_MINUTE = 60;
const SNAPSHOT_TTL_MINUTES = 5;
const MILLISECONDS_PER_SECOND = 1_000;
const SNAPSHOT_TTL_MS = SNAPSHOT_TTL_MINUTES * SECONDS_PER_MINUTE * MILLISECONDS_PER_SECOND;
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
type StockScreenerItem = {
countryOfRisk: null | string;
currency: null | string;
instrumentUid: string;
isin: null | string;
logoName: null | string;
lot: null | number;
name: string;
realExchange: null | string;
sector: null | string;
shareType: null | string;
ticker: null | string;
};
type StockScreenerPage = {
asOf: string;
hasNext: boolean;
items: StockScreenerItem[];
page: number;
searchId: string;
};
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
type TbankAccess = {
local(userId: string): Promise<{ revision: string }>;
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
};
@Injectable()
export class StockScreenerService {
private readonly snapshots = new Map<string, Snapshot>();
constructor(
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
private readonly client: TbankClient,
private readonly logger: StructuredLogger,
) {}
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
const { instruments, revision } = await this.access.online(userId, async (connection) => ({
instruments: await this.client.getAllShares(connection.token),
revision: connection.revision,
}));
const asOf = new Date().toISOString();
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
.instruments.map((instrument) => this.item(instrument));
const searchId = randomUUID();
this.evict();
this.snapshots.set(searchId, {
asOf,
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
items: selected,
revision,
userId,
});
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
return this.result(searchId, this.snapshots.get(searchId) as Snapshot, MIN_PAGE);
}
async page(userId: string, searchId: string, page: number): Promise<StockScreenerPage> {
const snapshot = this.snapshots.get(searchId);
if (!snapshot || snapshot.expiresAt <= Date.now()) {
this.snapshots.delete(searchId);
this.logger.event('info', 'stock.screener.result.missing', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
if (snapshot.userId !== userId) {
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
const current = await this.access.local(userId);
if (snapshot.revision !== current.revision) {
this.logger.event('warn', 'stock.screener.result.rejected', { operation: 'stock-screener-read' });
throw new NotFoundException();
}
return this.result(searchId, snapshot, page);
}
private result(searchId: string, snapshot: Snapshot, page: number): StockScreenerPage {
const start = (page - MIN_PAGE) * PAGE_SIZE;
if (!Number.isInteger(page) || page < MIN_PAGE || (start >= snapshot.items.length && !(page === MIN_PAGE && !snapshot.items.length))) {
throw new BadRequestException();
}
const items = snapshot.items.slice(start, start + PAGE_SIZE);
return { asOf: snapshot.asOf, hasNext: start + PAGE_SIZE < snapshot.items.length, items, page, searchId };
}
private item(instrument: TbankShare): StockScreenerItem {
if (!instrument.uid || !UUID_PATTERN.test(instrument.uid) || !instrument.name?.trim()) {
throw new ServiceUnavailableException();
}
return {
countryOfRisk: instrument.countryOfRisk ?? null,
currency: instrument.currency ?? null,
instrumentUid: instrument.uid,
isin: instrument.isin ?? null,
lot: instrument.lot ?? null,
logoName: instrument.brand?.logoName?.trim() || null,
name: instrument.name,
realExchange: instrument.realExchange ?? null,
sector: instrument.sector ?? null,
shareType: instrument.shareType ?? null,
ticker: instrument.ticker ?? null,
};
}
private evict(): void {
for (const [searchId, snapshot] of this.snapshots) {
if (snapshot.expiresAt <= Date.now()) this.snapshots.delete(searchId);
}
while (this.snapshots.size >= MAX_SNAPSHOTS) {
const searchId = this.snapshots.keys().next().value;
if (searchId && this.snapshots.size >= MINIMUM_SNAPSHOT_COUNT) this.snapshots.delete(searchId);
}
}
}
@@ -0,0 +1,188 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
const asOf = new Date('2026-10-10T12:00:00.000Z');
function share(overrides: Partial<TbankShare> = {}): TbankShare {
return {
apiTradeAvailableFlag: true,
buyAvailableFlag: true,
currency: 'RUB',
lot: 10,
name: 'North Star',
ticker: 'NSTR',
uid: 'uid-1',
...overrides,
};
}
describe('selectStockScreenerInstruments', () => {
it('keeps only instruments matching every selected condition', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'A', sector: 'energy' }),
share({ currency: 'USD', isin: 'B', sector: 'energy', uid: 'uid-2' }),
share({ isin: 'C', name: 'Other', sector: 'energy', uid: 'uid-3' }),
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
],
},
{ currency: 'RUB', query: 'star', sector: 'energy' },
);
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
});
it.each([
['sector', { sector: 'energy' }, share({ sector: undefined })],
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
['currency', { currency: 'USD' }, share({ currency: undefined })],
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
] as const)('excludes an unknown or unequal selected %s', (_condition, conditions, instrument) => {
const result = selectStockScreenerInstruments(
{ asOf, instruments: [instrument] },
conditions,
);
expect(result.instruments).toEqual([]);
});
it('uses an inclusive maximum lot and ignores lots when it is not selected', () => {
const instruments = [
share({ isin: 'equal', lot: 10, uid: 'equal' }),
share({ isin: 'large', lot: 11, uid: 'large' }),
share({ isin: 'unknown', lot: undefined, uid: 'unknown' }),
share({ isin: 'zero', lot: 0, uid: 'zero' }),
];
expect(
selectStockScreenerInstruments({ asOf, instruments }, { maxLot: 10 }).instruments.map(
({ uid }) => uid,
),
).toEqual(['equal']);
expect(
selectStockScreenerInstruments({ asOf, instruments }, {}).instruments.map(({ uid }) => uid),
).toEqual(['equal', 'large', 'unknown', 'zero']);
});
it('chooses the smallest matching uid as the complete representative of an ISIN', () => {
const chosen = share({ isin: ' same ', lot: 1, name: 'Chosen', uid: 'a' });
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'SAME', lot: 99, name: 'Sibling', uid: 'z' }), chosen],
},
{},
);
expect(result.instruments).toEqual([chosen]);
expect(result.instruments[0]).toMatchObject({ lot: 1, name: 'Chosen', uid: 'a' });
});
it('chooses the sole matching mode of an issue', () => {
const matching = share({ isin: 'same', sector: 'energy', uid: 'matching' });
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'same', sector: 'finance', uid: 'smaller-uid' }), matching],
},
{ sector: 'energy' },
);
expect(result.instruments).toEqual([matching]);
});
it('keeps different ISIN issues from one issuer separate', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'common', shareType: 'common', uid: 'common' }),
share({ isin: 'preferred', shareType: 'preferred', uid: 'preferred' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['common', 'preferred']);
});
it('treats instruments without an ISIN as separate issues', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: ' ', uid: 'first' }), share({ isin: undefined, uid: 'second' })],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['first', 'second']);
});
it('preserves exact whitespace-differing UIDs as separate blank-ISIN issues', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: ' ', uid: 'uid' }),
share({ isin: undefined, uid: ' uid ' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual([' uid ', 'uid']);
});
it('returns the exact chosen uid for the later card link', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [share({ isin: 'same', uid: 'sibling' }), share({ isin: 'SAME', uid: 'chosen' })],
},
{},
);
expect(result.instruments[0]?.uid).toBe('chosen');
});
it.each([
[true, true, 1],
[true, false, 0],
[false, true, 0],
[false, false, 0],
[true, undefined, 0],
[undefined, true, 0],
] as const)(
'includes only confirmed buy and API availability: buy=%s api=%s',
(buyAvailableFlag, apiTradeAvailableFlag, expectedCount) => {
const result = selectStockScreenerInstruments(
{ asOf, instruments: [share({ apiTradeAvailableFlag, buyAvailableFlag })] },
{},
);
expect(result.instruments).toHaveLength(expectedCount);
},
);
it('orders representatives by ticker and then uid before paging', () => {
const result = selectStockScreenerInstruments(
{
asOf,
instruments: [
share({ isin: 'c', ticker: 'B', uid: 'z' }),
share({ isin: 'a', ticker: 'A', uid: 'z' }),
share({ isin: 'b', ticker: 'A', uid: 'a' }),
share({ isin: 'empty', ticker: ' ', uid: 'empty' }),
],
},
{},
);
expect(result.instruments.map(({ uid }) => uid)).toEqual(['empty', 'a', 'z', 'z']);
});
});
@@ -0,0 +1,106 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
export type StockScreenerCatalog = {
asOf: Date;
instruments: TbankShare[];
};
export type StockScreenerConditions = {
countryOfRisk?: string;
currency?: string;
maxLot?: number;
query?: string;
realExchange?: string;
sector?: string;
shareType?: string;
};
type IdentifiedShare = TbankShare & { uid: string };
const sortBefore = 0;
export function selectStockScreenerInstruments(
catalog: StockScreenerCatalog,
conditions: StockScreenerConditions,
): StockScreenerCatalog {
const representatives = new Map<string, IdentifiedShare>();
for (const instrument of catalog.instruments) {
if (!matches(instrument, conditions)) continue;
const { uid } = instrument;
const isin = instrument.isin?.trim().toUpperCase();
const issueKey = isin ? `isin:${isin}` : `uid:${uid}`;
const existing = representatives.get(issueKey);
if (!existing || uid.localeCompare(existing.uid) < sortBefore) {
representatives.set(issueKey, instrument);
}
}
return {
asOf: catalog.asOf,
instruments: [...representatives.values()].sort(
(left, right) =>
(left.ticker?.trim() ?? '').localeCompare(right.ticker?.trim() ?? '') ||
left.uid.localeCompare(right.uid),
),
};
}
function matches(
instrument: TbankShare,
conditions: StockScreenerConditions,
): instrument is IdentifiedShare {
if (
!instrument.uid?.trim() ||
instrument.buyAvailableFlag !== true ||
instrument.apiTradeAvailableFlag !== true
) {
return false;
}
if (
conditions.query !== undefined &&
!`${instrument.name ?? ''}\n${instrument.ticker ?? ''}`
.toLowerCase()
.includes(conditions.query.toLowerCase())
) {
return false;
}
if (conditions.sector !== undefined && instrument.sector !== conditions.sector) {
return false;
}
if (
conditions.shareType !== undefined &&
instrument.shareType !== conditions.shareType
) {
return false;
}
if (
conditions.currency !== undefined &&
instrument.currency !== conditions.currency
) {
return false;
}
if (
conditions.realExchange !== undefined &&
instrument.realExchange !== conditions.realExchange
) {
return false;
}
if (
conditions.countryOfRisk !== undefined &&
instrument.countryOfRisk !== conditions.countryOfRisk
) {
return false;
}
const minimumPositiveLot = 0;
return (
conditions.maxLot === undefined ||
(Number.isInteger(instrument.lot) &&
instrument.lot !== undefined &&
instrument.lot > minimumPositiveLot &&
instrument.lot <= conditions.maxLot)
);
}
@@ -1,15 +1,21 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { import {
BadRequestException,
Body,
Controller, Controller,
Get, Get,
HttpCode,
HttpStatus,
Param, Param,
ParseUUIDPipe, ParseUUIDPipe,
Post,
Query, Query,
Req, Req,
UseGuards, UseGuards,
ValidationPipe,
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
@@ -18,6 +24,7 @@ import { GetStockCompanyUseCase } from '../application/get-stock-company.use-cas
import { StockCatalogService } from '../application/stock-catalog.service'; import { StockCatalogService } from '../application/stock-catalog.service';
import { StockIncomeService } from '../application/stock-income.service'; import { StockIncomeService } from '../application/stock-income.service';
import { StockMarketService } from '../application/stock-market.service'; import { StockMarketService } from '../application/stock-market.service';
import { StockScreenerService } from '../application/stock-screener.service';
import { import {
StockCandlesQueryDto, StockCandlesQueryDto,
StockCandlesResourceDto, StockCandlesResourceDto,
@@ -26,6 +33,9 @@ import {
StockPositionResourceDto, StockPositionResourceDto,
StockQuoteResourceDto, StockQuoteResourceDto,
StockResourceDto, StockResourceDto,
StockScreenerCriteriaDto,
StockScreenerPageDto,
StockScreenerPageQueryDto,
StockSearchQueryDto, StockSearchQueryDto,
StockSearchResourceDto, StockSearchResourceDto,
StockTickerParamsDto, StockTickerParamsDto,
@@ -42,6 +52,7 @@ export class StockController {
private readonly market: StockMarketService, private readonly market: StockMarketService,
private readonly income: StockIncomeService, private readonly income: StockIncomeService,
private readonly companyInfo: GetStockCompanyUseCase, private readonly companyInfo: GetStockCompanyUseCase,
private readonly screener: StockScreenerService,
) {} ) {}
@Get() @Get()
@@ -71,6 +82,33 @@ export class StockController {
); );
} }
@Post('screener/search')
@HttpCode(HttpStatus.OK)
@ApiOperation({ operationId: 'createStockScreenerSearch' })
@ApiOkResponse({ type: StockScreenerPageDto })
searchScreener(
@Req() request: Request & { user: AccessPayload },
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
criteria: StockScreenerCriteriaDto,
) {
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
throw new BadRequestException();
}
return this.screener.search(request.user.sub, criteria);
}
@Get('screener/search/:searchId')
@ApiOperation({ operationId: 'readStockScreenerPage' })
@ApiOkResponse({ type: StockScreenerPageDto })
pageScreener(
@Req() request: Request & { user: AccessPayload },
@Param('searchId') searchId: string,
@Query() query: StockScreenerPageQueryDto,
) {
return this.screener.page(request.user.sub, searchId, query.page);
}
@Get(':uid') @Get(':uid')
@ApiOkResponse({ type: StockResourceDto }) @ApiOkResponse({ type: StockResourceDto })
get( get(
@@ -111,13 +149,8 @@ export class StockController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/company') @Get(':uid/company')
@@ -2,10 +2,12 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsIn, IsIn,
IsInt,
IsOptional, IsOptional,
IsString, IsString,
Matches, Matches,
MaxLength, MaxLength,
Min,
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
@@ -21,6 +23,86 @@ import {
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
const MIN_TICKER_LENGTH = 1; const MIN_TICKER_LENGTH = 1;
const MAX_SCREENER_QUERY_LENGTH = 80;
const MIN_PAGE = 1;
export class StockScreenerCriteriaDto {
@ApiPropertyOptional({ maxLength: MAX_SCREENER_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) =>
value === '' ? undefined : value,
)
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
@MaxLength(MAX_SCREENER_QUERY_LENGTH)
query?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
sector?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
shareType?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
currency?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
realExchange?: string;
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
@IsOptional()
@IsString()
@MinLength(MIN_TICKER_LENGTH)
countryOfRisk?: string;
@ApiPropertyOptional({ minimum: MIN_TICKER_LENGTH, type: 'integer' })
@IsOptional()
@IsInt()
@Min(MIN_TICKER_LENGTH)
maxLot?: number;
}
export class StockScreenerPageQueryDto {
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' })
@Transform(({ value }: { value: unknown }) => Number(value))
@IsInt()
@Min(MIN_PAGE)
declare page: number;
}
export class StockScreenerItemDto {
@ApiProperty({ format: 'uuid' }) declare instrumentUid: string;
@ApiProperty() declare name: string;
@ApiProperty({ nullable: true, type: String }) declare ticker: null | string;
@ApiProperty({ nullable: true, type: String }) declare isin: null | string;
@ApiProperty({ nullable: true, type: String }) declare logoName: null | string;
@ApiProperty({ nullable: true, type: String }) declare shareType: null | string;
@ApiProperty({ nullable: true, type: String }) declare sector: null | string;
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
@ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number;
}
export class StockScreenerPageDto {
@ApiProperty() declare searchId: string;
@ApiProperty({ format: 'date-time' }) declare asOf: string;
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
@ApiProperty() declare hasNext: boolean;
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
}
export class StockSearchQueryDto extends InvestmentReadQueryDto { export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@@ -1,9 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator'; import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const; export const availabilityValues = [
'available',
'initial-loading',
'temporarily-unavailable',
'connection-unavailable',
] as const;
export const completenessValues = ['complete', 'incomplete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const; export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
export const syncStateValues = ['idle', '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);
} }
@@ -17,7 +17,7 @@ describe('screen dependency and history planning', () => {
}, },
], ],
}; };
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => { it('prioritizes discovery, the selected period, then history; Moscow dates are converted once', () => {
const plan = planInvestmentScreen( const plan = planInvestmentScreen(
{ {
screen: 'income', screen: 'income',
@@ -32,6 +32,17 @@ describe('screen dependency and history planning', () => {
const selected = plan.sources.filter( const selected = plan.sources.filter(
(source) => source.kind === 'operations' && source.priority === 'screen', (source) => source.kind === 'operations' && source.priority === 'screen',
); );
// The single selected range is chunked into month-aligned intervals.
expect(selected).toHaveLength(2);
expect(
selected.every(
(source) =>
source.from != null &&
source.from.toISOString() >= '2024-01-31T21:00:00.000Z' &&
source.to != null &&
source.to.toISOString() <= '2024-02-29T21:00:00.000Z',
),
).toBe(true);
expect( expect(
selected.some( selected.some(
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z', (source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
@@ -42,9 +53,7 @@ describe('screen dependency and history planning', () => {
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z', (source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
), ),
).toBe(true); ).toBe(true);
expect(plan.unavailable).toEqual([ expect(plan.unavailable).toEqual([]);
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
]);
const firstHistory = plan.sources.findIndex( const firstHistory = plan.sources.findIndex(
(source) => source.priority === 'history', (source) => source.priority === 'history',
); );
@@ -111,13 +111,6 @@ export function planInvestmentScreen(
priority: 'screen' as const, priority: 'screen' as const,
})), })),
); );
const knownOpening =
accounts.length && accounts.every((account) => account.openedOn)
? new Date(
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
)
: undefined;
if (!context.membershipKnown) return { sources, unavailable, now }; if (!context.membershipKnown) return { sources, unavailable, now };
if ( if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen) ['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
@@ -142,23 +135,9 @@ export function planInvestmentScreen(
const period = planIncomePeriod( const period = planIncomePeriod(
screen.screen === 'income' ? screen.from : undefined, screen.screen === 'income' ? screen.from : undefined,
screen.screen === 'income' ? screen.to : undefined, screen.screen === 'income' ? screen.to : undefined,
knownOpening,
); );
for (const range of [ const bounds = dateOnlyBounds(period.selected, now);
period.selected, operations(bounds.from, bounds.to);
...(screen.screen === 'income'
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
: []),
]) {
const bounds = dateOnlyBounds(range, now);
operations(bounds.from, bounds.to);
}
if (screen.screen === 'income' && !period.yearAgo) {
unavailable.push({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
reason: 'calendar_or_account_boundary',
});
}
if (screen.screen === 'account') { if (screen.screen === 'account') {
const range = ledgerRange( const range = ledgerRange(
screen.from, screen.from,
@@ -209,6 +209,12 @@ export class BrokerageOperationsSyncService {
'tbank.operations.sync.persistence_failed', 'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: TbankErrorCode.Unavailable }, { userId: attempt.userId, code: TbankErrorCode.Unavailable },
); );
// The step could not even record its failure; close the attempt
// durably so it does not stay running. Best effort — an open row
// is re-reserved by the next request.
await this.repository
.fail(attempt, false)
.catch(() => undefined);
} }
const next = pending ? await this.repository.pending(attempt) : null; const next = pending ? await this.repository.pending(attempt) : null;
if (next) { if (next) {
@@ -17,6 +17,10 @@ import {
ledgerRange, ledgerRange,
planInvestmentScreen, planInvestmentScreen,
} from '../../investment-sync-plan'; } from '../../investment-sync-plan';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.repository';
import { OperationErrorCode } from '../domain/operation-error-code'; import { OperationErrorCode } from '../domain/operation-error-code';
import { import {
confirmedOperationRanges, confirmedOperationRanges,
@@ -59,6 +63,7 @@ export class BrokerageOperationsService {
private readonly sync: BrokerageOperationsSyncService, private readonly sync: BrokerageOperationsSyncService,
private readonly membership: TbankMembershipService, private readonly membership: TbankMembershipService,
private readonly config: ConfigService, private readonly config: ConfigService,
private readonly hourlySync: TbankSyncRepository,
) {} ) {}
async read( async read(
@@ -135,52 +140,17 @@ export class BrokerageOperationsService {
? [{ accountId: source.accountId, from: source.from, to: source.to }] ? [{ accountId: source.accountId, from: source.from, to: source.to }]
: [], : [],
); );
if (query.sync !== 'observe') { // The ledger only starts the first-time load of missing intervals; the
const discovery = await this.membership.request( // mutable tail belongs to the hourly walk and membership is not rechecked.
userId, if (intervals.length) {
connection.revision, await this.sync.readPlan(userId, intervals, {
); movingEnd,
if (discovery) { history: true,
void discovery.done revision: connection.revision,
.then(async () => { });
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
const currentRange = ledgerRange(
query.from,
query.to,
query.instrumentUid,
current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
),
now,
);
const targets = current.accounts
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap((account) =>
currentRange.from < currentRange.to
? [{ accountId: account.accountId, ...currentRange }]
: [],
);
await this.sync.readPlan(userId, targets, {
movingEnd,
history: true,
revision: connection.revision,
});
})
.catch(() => undefined);
} else if (intervals.length) {
await this.sync.readPlan(userId, intervals, {
movingEnd,
history: true,
revision: connection.revision,
});
}
} }
const saved = await this.repository.readPage(readInput); const saved = await this.repository.readPage(readInput);
const hourly = await this.hourlySync.hourly(userId);
const accounts = saved?.connection.accounts ?? []; const accounts = saved?.connection.accounts ?? [];
const targets = accounts.flatMap((account) => const targets = accounts.flatMap((account) =>
from < to from < to
@@ -252,26 +222,10 @@ export class BrokerageOperationsService {
dataRevision: value.dataRevision, dataRevision: value.dataRevision,
availability: 'complete', availability: 'complete',
fetchedAt: value.fetchedAt, fetchedAt: value.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: tbankSourceTtlMs.portfolio,
work: null, work: null,
}), }),
); );
if (query.sync !== 'observe') {
const identities = new Map(
rows.map((row) => {
const identity = row.payload as TbankOperationItem;
return [
metadataKey(identity),
{ instrumentUid: identity.instrumentUid, figi: identity.figi },
] as const;
}),
);
for (const identity of identities.values()) {
if (!instruments.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -295,6 +249,7 @@ export class BrokerageOperationsService {
? STALE_FRESHNESS_MS ? STALE_FRESHNESS_MS
: Number.POSITIVE_INFINITY, : Number.POSITIVE_INFINITY,
work: work ?? saved?.membershipWork ?? null, work: work ?? saved?.membershipWork ?? null,
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membershipMeta, ...metadataDependencies], dependencies: [membershipMeta, ...metadataDependencies],
coverage: { coverage: {
requested: targets.map(serialize), requested: targets.map(serialize),
@@ -346,7 +301,7 @@ export class BrokerageOperationsService {
const hasNext = Boolean(saved && saved.rows.length > limit); const hasNext = Boolean(saved && saved.rows.length > limit);
return { return {
data: data:
meta.availability === 'missing' meta.completeness === 'incomplete'
? null ? null
: { : {
accountId, accountId,
@@ -480,6 +435,7 @@ export class BrokerageOperationsService {
saved = await this.repository.readPage(input); saved = await this.repository.readPage(input);
} }
const now = new Date(); const now = new Date();
const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({ const membership = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION), dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -496,7 +452,11 @@ export class BrokerageOperationsService {
key: `${period.from}/${period.to}`, key: `${period.from}/${period.to}`,
movingEnd: false, movingEnd: false,
})); }));
return periods.map((period, index) => { const rows = saved?.rows ?? [];
const instruments = await this.metadata.savedMany(
rows.map((row) => row.payload as TbankOperationItem),
);
return periods.map((period) => {
const targets = (saved?.connection.accounts ?? []).map((account) => ({ const targets = (saved?.connection.accounts ?? []).map((account) => ({
accountId: account.accountId, accountId: account.accountId,
...period, ...period,
@@ -532,8 +492,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 +504,7 @@ export class BrokerageOperationsService {
fetchedAt, fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity, freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work, work,
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membership], dependencies: [membership],
coverage: { coverage: {
requested: targets.map(serialize), requested: targets.map(serialize),
@@ -587,7 +547,7 @@ export class BrokerageOperationsService {
return { return {
meta, meta,
items: complete items: complete
? (saved?.rows ?? []) ? rows
.filter((row) => .filter((row) =>
plan.intervals.some( plan.intervals.some(
(interval) => (interval) =>
@@ -601,6 +561,10 @@ export class BrokerageOperationsService {
row.payload as TbankOperationItem, row.payload as TbankOperationItem,
row.occurredAt, row.occurredAt,
), ),
logoName:
instruments
.get(metadataKey(row.payload as TbankOperationItem))
?.data?.brand?.logoName?.trim() || null,
...(accountId === 'all' ...(accountId === 'all'
? { ? {
account: { account: {
@@ -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 {
@@ -2,13 +2,14 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
import { BrokerageOperationsService } from './application/brokerage-operations.service'; import { BrokerageOperationsService } from './application/brokerage-operations.service';
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository'; import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller'; import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
@Module({ @Module({
imports: [AuthModule, TbankModule], imports: [AuthModule, TbankModule, TbankSyncRepositoryModule],
controllers: [BrokerageOperationsController], controllers: [BrokerageOperationsController],
providers: [ providers: [
BrokerageOperationsService, BrokerageOperationsService,
@@ -15,10 +15,7 @@ import {
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto'; import { AccountReferenceDto } from '../../account-reference.dto';
import { import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
InvestmentReadQueryDto,
InvestmentResourceMetaDto,
} from '../../investment-resource.dto';
const MIN_OPERATIONS_PAGE_SIZE = 1; const MIN_OPERATIONS_PAGE_SIZE = 1;
@@ -36,7 +33,7 @@ export const operationCategories = [
] as const; ] as const;
export type OperationCategory = (typeof operationCategories)[number]; export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto extends InvestmentReadQueryDto { export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'uuid', format: 'uuid',
description: description:
@@ -2,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,6 +17,7 @@ export class ForecastService {
async result<T extends object>(input: { async result<T extends object>(input: {
candidate?: T; candidate?: T;
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
membershipRevision: number; membershipRevision: number;
portfolio: InvestmentResourceMetaDto; portfolio: InvestmentResourceMetaDto;
revision: string; revision: string;
@@ -72,22 +74,51 @@ 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) : 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): T {
if (!scopeKey.startsWith('events/')) return payload;
try {
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
const value = payload as T & {
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
incomeTotalsByCurrency?: { currency: string; value: string }[];
};
if (!value.events || !value.incomeTotalsByCurrency) return payload;
const events = value.events.filter((event) => event.date >= from && event.date <= to);
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
}
return {
...value,
events,
incomeTotalsByCurrency: [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
};
} catch {
return payload;
}
}
@@ -1,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 {},
})); }));
@@ -26,6 +26,7 @@ describe('persistent portfolio projection', () => {
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',
@@ -40,6 +41,7 @@ describe('persistent portfolio projection', () => {
accounts: [], accounts: [],
work: [], work: [],
}); });
repository.publish.mockResolvedValue(true);
metadata.savedMany.mockResolvedValue(new Map()); metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null); metadata.request.mockResolvedValue(null);
}); });
@@ -95,14 +97,9 @@ describe('persistent portfolio projection', () => {
], ],
work: [], work: [],
}); });
await service.read('user', 'a'); const handle = await service.request('user', 'private', 'a');
for ( assert(handle);
let index = 0; await handle.done;
index < 10 && !repository.publish.mock.calls.length;
index += 1
) {
await immediate();
}
const snapshot = ( const snapshot = (
repository.publish.mock.calls as [unknown, string, PortfolioDto][] repository.publish.mock.calls as [unknown, string, PortfolioDto][]
)[0][2]; )[0][2];
@@ -145,10 +142,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' },
@@ -189,10 +185,11 @@ describe('persistent portfolio projection', () => {
accounts: [account, { ...account, accountId: 'b', portfolio: null }], accounts: [account, { ...account, accountId: 'b', portfolio: null }],
work: [], work: [],
}); });
const partial = await service.read('user', 'all', true); const partial = await service.read('user', 'all');
expect(partial.data).toBeNull(); expect(partial.data).toBeNull();
expect(partial.meta.availability).toBe('partial'); expect(partial.meta.availability).toBe('temporarily-unavailable');
const saved = await service.saved('user', 'all', true); expect(partial.meta.completeness).toBe('incomplete');
const saved = await service.saved('user', 'all');
expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual( expect(saved.accounts[0].snapshot?.totalsByCurrency).toEqual(
snapshot.totalsByCurrency, snapshot.totalsByCurrency,
); );
@@ -210,7 +207,7 @@ describe('persistent portfolio projection', () => {
], ],
work: [], work: [],
}); });
const complete = await service.read('user', 'all', true); const complete = await service.read('user', 'all');
expect(complete.data?.totalsByCurrency).toEqual([ expect(complete.data?.totalsByCurrency).toEqual([
{ currency: 'RUB', value: '2.000000002' }, { currency: 'RUB', value: '2.000000002' },
]); ]);
@@ -8,7 +8,6 @@ import { StructuredLogger } from '../../../../common/logging/structured-logger.s
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,6 +17,10 @@ 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.repository';
import { portfolioStructure } from '../domain/portfolio-structure'; import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals'; import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import { import {
@@ -29,6 +32,7 @@ import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
import { PortfolioDto } from '../presentation/portfolio.dto'; import { PortfolioDto } from '../presentation/portfolio.dto';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const NO_ACCOUNTS = 0;
const membershipFreshForMs = tbankSourceTtlMs.membership; const membershipFreshForMs = tbankSourceTtlMs.membership;
@@ -45,50 +49,14 @@ export class PortfolioService {
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 +64,6 @@ export class PortfolioService {
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (!observe) {
const identities = new Map<string, MetadataIdentity>();
for (const account of (saved?.accounts ?? []).filter(
(account) => accountId === 'all' || account.accountId === accountId,
)) {
const snapshot = account.portfolio?.payload as unknown as
PortfolioDto | undefined;
for (const position of snapshot?.positions ?? []) {
identities.set(metadataKey(position), position);
}
}
const savedMetadata = await this.metadata.savedMany([
...identities.values(),
]);
for (const identity of identities.values()) {
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
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 +71,10 @@ export class PortfolioService {
fetchedAt: saved?.membershipAt ?? null, fetchedAt: saved?.membershipAt ?? null,
freshForMs: membershipFreshForMs, freshForMs: membershipFreshForMs,
work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null, work: saved?.work.find((work) => work.scopeKey === 'membership') ?? null,
lastAttempt: hourlyAttempt(hourly, 'membership'),
}); });
return { return {
hourly,
membership: membershipMeta, membership: membershipMeta,
accounts: (saved?.accounts ?? []) accounts: (saved?.accounts ?? [])
.filter( .filter(
@@ -154,16 +104,21 @@ export class PortfolioService {
saved?.work.find( saved?.work.find(
(work) => work.scopeKey === portfolioWorkKey(account.accountId), (work) => work.scopeKey === portfolioWorkKey(account.accountId),
) ?? null, ) ?? null,
lastAttempt: hourlyAttempt(hourly, 'portfolio', account.accountId),
}), }),
})), })),
}; };
} }
async read(userId: string, accountId: string, observe = false) { async read(userId: string, accountId: string) {
const saved = await this.saved(userId, accountId, observe); const { hourly, ...saved } = await this.saved(userId, accountId);
const missing = const missing =
saved.membership.availability === 'missing' || saved.membership.completeness === 'incomplete' ||
saved.accounts.some((account) => !account.snapshot); (saved.accounts.length > NO_ACCOUNTS && saved.accounts.some((account) => !account.snapshot));
const available =
!missing &&
(saved.membership.availability === 'available' ||
saved.accounts.some((account) => account.snapshot));
const metadata = await this.metadata.savedMany( const metadata = await this.metadata.savedMany(
saved.accounts.flatMap((account) => account.snapshot?.positions ?? []), saved.accounts.flatMap((account) => account.snapshot?.positions ?? []),
); );
@@ -176,7 +131,7 @@ export class PortfolioService {
dataRevision: source.dataRevision, dataRevision: source.dataRevision,
availability: 'complete', availability: 'complete',
fetchedAt: source.fetchedAt, fetchedAt: source.fetchedAt,
freshForMs: tbankSourceTtlMs.metadata, freshForMs: portfolioFreshForMs,
work: null, work: null,
}), }),
] ]
@@ -212,6 +167,11 @@ export class PortfolioService {
] ]
: [], : [],
); );
let availability: 'available' | 'complete' | 'missing' | 'partial';
if (available) availability = 'available';
else if (!missing) availability = 'complete';
else if (portfolios.length > NO_ACCOUNTS) availability = 'partial';
else availability = 'missing';
const lastSuccessfulAt = const lastSuccessfulAt =
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings. // eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ?? portfolios.map((portfolio) => portfolio.lastSuccessfulAt).sort()[0] ??
@@ -255,16 +215,13 @@ export class PortfolioService {
]), ]),
metadataDependencies.map((dependency) => dependency.dataRevision), metadataDependencies.map((dependency) => dependency.dataRevision),
]), ]),
availability: missing availability,
? portfolios.length
? 'partial'
: 'missing'
: 'complete',
fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null, fetchedAt: lastSuccessfulAt ? new Date(lastSuccessfulAt) : null,
freshForMs: portfolios.length freshForMs: portfolios.length
? portfolioFreshForMs ? portfolioFreshForMs
: membershipFreshForMs, : membershipFreshForMs,
work: work.id ? { ...work, id: work.id } : null, work: work.id ? { ...work, id: work.id } : null,
lastAttempt: hourlyAttempt(hourly, 'portfolio', accountId === 'all' ? undefined : accountId),
dependencies: [ dependencies: [
saved.membership, saved.membership,
...saved.accounts.map((account) => account.meta), ...saved.accounts.map((account) => account.meta),
@@ -51,11 +51,32 @@ 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 },
}); });
const requestedWindow = eventsWindow(input.scopeKey);
const saved = previous ?? (requestedWindow
? (
await tx.tbankForecast.findMany({
where: {
...owner,
scopeKey: { startsWith: 'events/' },
membershipRevision: input.membershipRevision,
},
})
).find((forecast) => {
const window = eventsWindow(forecast.scopeKey);
return (
window &&
window.accountId === requestedWindow.accountId &&
window.from <= requestedWindow.to &&
requestedWindow.from <= window.to
);
})
: null);
const fallback = const fallback =
previous?.membershipRevision === input.membershipRevision saved?.membershipRevision === input.membershipRevision
? previous ? saved
: null; : null;
if (!input.candidate) return fallback; if (!input.candidate) return fallback;
if (!previous && fallback) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({ const portfolios = await tx.tbankPortfolio.findMany({
where: { where: {
...owner, ...owner,
@@ -154,3 +175,13 @@ export class ForecastRepository {
}); });
} }
} }
function eventsWindow(scopeKey: string) {
if (!scopeKey.startsWith('events/')) return null;
try {
const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
return { accountId, from, to };
} catch {
return null;
}
}
@@ -69,17 +69,23 @@ export class PortfolioRepository {
const snapshot = await tx.tbankPortfolio.findUnique({ const snapshot = await tx.tbankPortfolio.findUnique({
where: { userId_revision_accountId: { userId, revision, accountId } }, where: { userId_revision_accountId: { userId, revision, accountId } },
}); });
const portfolioWorkScope = {
userId,
revision,
scopeKey: portfolioWorkKey(accountId),
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: portfolioWorkScope },
});
if ( if (
!force && !force &&
snapshot && snapshot &&
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) &&
previous?.state !== 'failed'
) { ) {
return null; return null;
} }
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const scope = portfolioWorkScope;
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if ( if (
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
@@ -139,6 +145,41 @@ export class PortfolioRepository {
create: { ...scope, ...data }, create: { ...scope, ...data },
update: { ...data, dataRevision: { increment: 1 } }, update: { ...data, dataRevision: { increment: 1 } },
}); });
// Value history points require a fully valued snapshot; the attempt
// identity deduplicates repeated or late publications of one attempt.
if (
snapshot.totalsByCurrency.length > Number.MIN_VALUE &&
snapshot.positions.every((position) => position.currentValue !== null)
) {
await tx.tbankPortfolioValueHistory.upsert({
where: {
userId_accountId: { userId: attempt.userId, accountId },
},
create: { userId: attempt.userId, accountId },
update: { deletedAt: null },
});
for (const { currency, value } of snapshot.totalsByCurrency) {
await tx.tbankPortfolioValuePoint.upsert({
where: {
userId_accountId_attemptId_currency: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
},
},
create: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
value,
capturedAt: data.fetchedAt,
},
update: {},
});
}
}
return true; return true;
}); });
} }
@@ -2,6 +2,7 @@ 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 { ForecastService } from './application/forecast.service'; import { ForecastService } from './application/forecast.service';
import { PortfolioService } from './application/portfolio.service'; import { PortfolioService } from './application/portfolio.service';
import { ForecastRepository } from './infrastructure/forecast.repository'; import { ForecastRepository } from './infrastructure/forecast.repository';
@@ -9,7 +10,7 @@ import { PortfolioRepository } from './infrastructure/portfolio.repository';
import { PortfolioController } from './presentation/portfolio.controller'; import { PortfolioController } from './presentation/portfolio.controller';
@Module({ @Module({
imports: [AuthModule, TbankModule], imports: [AuthModule, TbankModule, TbankSyncRepositoryModule],
controllers: [PortfolioController], controllers: [PortfolioController],
providers: [ providers: [
PortfolioService, PortfolioService,
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, 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 { PortfolioService } from '../application/portfolio.service'; import { PortfolioService } from '../application/portfolio.service';
import { PortfolioResourceDto } from './portfolio.dto'; import { PortfolioResourceDto } from './portfolio.dto';
@@ -19,15 +18,8 @@ export class PortfolioController {
@Get('all/portfolio') @Get('all/portfolio')
@ApiOkResponse({ type: PortfolioResourceDto }) @ApiOkResponse({ type: PortfolioResourceDto })
all( all(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.portfolio.read(request.user.sub, 'all');
@Query() query: InvestmentReadQueryDto,
) {
return this.portfolio.read(
request.user.sub,
'all',
query.sync === 'observe',
);
} }
@Get(':accountId/portfolio') @Get(':accountId/portfolio')
@@ -35,12 +27,7 @@ export class PortfolioController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.portfolio.read( return this.portfolio.read(request.user.sub, accountId);
request.user.sub,
accountId,
query.sync === 'observe',
);
} }
} }
@@ -2,11 +2,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 { TbankSyncModule } from '../tbank-sync/tbank-sync.module';
import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionController } from './tbank-connection.controller';
import { TbankConnectionService } from './tbank-connection.service'; import { TbankConnectionService } from './tbank-connection.service';
@Module({ @Module({
imports: [AuthModule, TbankModule], imports: [AuthModule, TbankModule, TbankSyncModule],
controllers: [TbankConnectionController], controllers: [TbankConnectionController],
providers: [TbankConnectionService], providers: [TbankConnectionService],
}) })
@@ -35,11 +35,22 @@ export class TbankConnectionService {
this.tbank.validateToken(token), this.tbank.validateToken(token),
); );
const encryptedToken = this.tokens.encrypt(token); const encryptedToken = this.tokens.encrypt(token);
const confirmed = supportedTbankAccounts(accounts).map(({ id }) => id);
const connection = await this.prisma.$transaction(async (tx) => { const connection = await this.prisma.$transaction(async (tx) => {
// Cascade old private data and attempts in the same transaction that // Cascade old private data and attempts in the same transaction that
// publishes the validated membership and the new connection revision. // publishes the validated membership and the new connection revision.
await tx.tbankConnection.deleteMany({ where: { userId } }); await tx.tbankConnection.deleteMany({ where: { userId } });
const now = new Date(); const now = new Date();
// Value history survives token replacement but is visible only for
// accounts confirmed by the new token.
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { in: confirmed } },
data: { deletedAt: null },
});
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, accountId: { notIn: confirmed }, deletedAt: null },
data: { deletedAt: now },
});
return tx.tbankConnection.create({ return tx.tbankConnection.create({
data: { data: {
userId, userId,
@@ -66,6 +77,14 @@ export class TbankConnectionService {
} }
async delete(userId: string): Promise<void> { async delete(userId: string): Promise<void> {
await this.prisma.tbankConnection.deleteMany({ where: { userId } }); await this.prisma.$transaction(async (tx) => {
await tx.tbankConnection.deleteMany({ where: { userId } });
// History stays stored but hidden until a new connection reconfirms
// the same account.
await tx.tbankPortfolioValueHistory.updateMany({
where: { userId, deletedAt: null },
data: { deletedAt: new Date() },
});
});
} }
} }
@@ -0,0 +1,415 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../common/logging/structured-logger.service';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import {
TbankAccessService,
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service';
import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service';
import {
HOURLY_CAUSE_INTERRUPTED,
type HourlyCause,
type HourlyChild,
type HourlySection,
TbankSyncRepository,
} from './tbank-sync.repository';
const HOUR_MS = 3_600_000;
const TICK_MS = 60_000;
const WORK_POLL_MS = 100;
const WORK_POLL_BACKOFF_FACTOR = 2;
const WORK_POLL_MAX_MS = 5_000;
// ponytail: fixed 15-minute step wait bound (900_000 ms), no config knob;
// make it configurable if slow steps become regular.
const WORK_WAIT_BOUND_MS = 900_000;
// An await that outlived WORK_WAIT_BOUND_MS. The underlying call keeps
// running; its late durable result is fenced like any other late publication.
const BOUND_EXPIRED = Symbol('hourly-bound-expired');
const FIRST_DAY_OF_MONTH = 1;
const NO_MILLISECONDS = 0;
type DispatchStep = (child: HourlyChild) => Promise<void>;
type Accounts = Awaited<ReturnType<TbankAccessService['local']>>['accounts'];
// One native timer per process. The timer only proposes work; the durable
// reservation in TbankSyncRepository is the fence that keeps a single
// attempt per connection. The deployment runs a single backend process.
// ponytail: no inter-process lease; add one before scaling past one process.
@Injectable()
export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
private readonly processRunId = randomUUID();
// Attempt ids of walks currently executing in this process; a running row
// outside this set lost its settle and is closed by recovery.
private readonly active = new Set<string>();
private timer: NodeJS.Timeout | null = null;
private ticking = false;
constructor(
private readonly prisma: PrismaService,
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly events: UpcomingEventsService,
private readonly logger: StructuredLogger,
) {}
onModuleInit() {
this.timer = setInterval(() => void this.tick(), TICK_MS);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
async tick() {
if (this.ticking) return;
this.ticking = true;
try {
const connections = await this.prisma.tbankConnection.findMany({
where: { invalidToken: false },
select: { userId: true },
});
for (const { userId } of connections) {
await this.launch(userId);
}
} catch {
// A failed tick only delays the proposal until the next tick.
} finally {
this.ticking = false;
}
}
// A fresh connection starts its first walk immediately, regardless of the
// previous term of the replaced connection.
private async launch(userId: string) {
await this.recoverAbandoned(userId);
const reserved = await this.repository.reserveHourly(
userId,
this.processRunId,
HOUR_MS,
);
if (!reserved) return null;
const { userId: owner, revision, id, attemptId } = reserved.work;
setImmediate(() => {
void this.walk({ userId: owner, revision, id, attemptId });
});
return reserved.work;
}
// A running hourly row of this process that has no active walk lost its
// settle (suppressed persistence error). Close it from its durable step
// results; rows of other processes are closed by the reservation restart
// branch. Rides the existing tick: no new timers or sanitors.
private async recoverAbandoned(userId: string) {
const rows = await this.repository.runningHourly(userId);
for (const row of rows) {
if (row.processRunId !== this.processRunId) continue;
if (this.active.has(row.attemptId)) continue;
await this.repository
.settleHourly(
{
userId,
revision: row.revision,
id: row.id,
attemptId: row.attemptId,
},
HOURLY_CAUSE_INTERRUPTED,
)
.catch(() => undefined);
}
}
// One membership pass, then the owners refresh their own datasets. The
// walk publishes only safe section results and holds no transaction or
// upstream call inside one. The attempt outcome is derived from durable
// step rows by the repository, never from walk-local state.
private async walk(attempt: TbankSyncAttempt) {
this.active.add(attempt.attemptId);
try {
const { userId, revision } = attempt;
const children: HourlyChild[] = [];
// Required sections whose dispatch failed never get a durable step
// row; they are recorded as problems so the attempt cannot succeed.
const dispatchProblems: HourlySection[] = [];
const dispatch: DispatchStep = async (child) => {
children.push(child);
await this.repository.recordHourlyChild(attempt, child);
};
const accounts = await this.refreshMembership(
attempt,
dispatch,
dispatchProblems,
);
if (!accounts) return;
await this.refreshAccounts(
userId,
revision,
accounts,
dispatch,
dispatchProblems,
);
await this.waitWorks(userId, children.map((child) => child.id));
for (const account of accounts) {
const pending = this.events
.get(userId, account.accountId)
.catch(() => undefined);
await this.withBound(pending);
}
await this.settle(attempt, null, dispatchProblems);
} catch {
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
} finally {
this.active.delete(attempt.attemptId);
}
}
// Returns the current accounts, or null when the attempt is already
// settled (invalid token or a replaced connection revision).
private async refreshMembership(
attempt: TbankSyncAttempt,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
const { userId, revision } = attempt;
const member = await this.withBound(
this.membership.request(userId, revision, true),
);
if (member && member !== BOUND_EXPIRED) {
await dispatch({ id: member.id, section: 'membership' });
const done = await this.withBound(member.done);
if (
done !== BOUND_EXPIRED &&
(await this.repository.workError(userId, member.id)) ===
TbankErrorCode.InvalidToken
) {
await this.settle(attempt);
return null;
}
} else {
// No durable step id (rejected request or an expired wait): record the
// observable problem; a late row stays fenced by its durable attempt id.
dispatchProblems.push({
section: 'membership',
errorCode: TbankErrorCode.Unavailable,
});
}
const current = await this.withBound(this.access.local(userId));
if (current === BOUND_EXPIRED || current.revision !== revision) {
await this.settle(attempt, HOURLY_CAUSE_INTERRUPTED);
return null;
}
return current.accounts;
}
// Races a step await against the same per-step bound the row wait uses, so
// a dispatch that never resolves cannot hold the attempt running forever.
// The call itself is not cancelled; a rejection landing after the expiry is
// swallowed here because the expiry already accounts the step as a problem.
private withBound<T>(step: Promise<T>): Promise<T | typeof BOUND_EXPIRED> {
step.catch(() => undefined);
return new Promise<T | typeof BOUND_EXPIRED>((resolve, reject) => {
const timer = setTimeout(
() => resolve(BOUND_EXPIRED),
WORK_WAIT_BOUND_MS,
);
step.then(
(value) => {
clearTimeout(timer);
resolve(value);
},
(error: unknown) => {
clearTimeout(timer);
reject(error instanceof Error ? error : new Error(String(error)));
},
);
});
}
private async refreshAccounts(
userId: string,
revision: string,
accounts: Accounts,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
for (const account of accounts) {
await this.dispatchPortfolio(
userId,
revision,
account.accountId,
dispatch,
dispatchProblems,
);
}
for (const account of accounts) {
await this.dispatchOperations(
userId,
revision,
account.accountId,
dispatch,
dispatchProblems,
);
}
for (const account of accounts) {
await this.dispatchEvents(
userId,
account.accountId,
dispatch,
dispatchProblems,
);
}
}
private async dispatchPortfolio(
userId: string,
revision: string,
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
try {
const work = await this.withBound(
this.portfolio.request(userId, revision, accountId, true),
);
if (work && work !== BOUND_EXPIRED) {
await dispatch({ accountId, id: work.id, section: 'portfolio' });
return;
}
} catch {
// A failed dispatch has no durable step row; record the problem.
}
dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'portfolio',
});
}
private async dispatchOperations(
userId: string,
revision: string,
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
try {
const id = await this.withBound(
this.operations.request(
userId,
[
{
accountId,
from: monthStartOf(new Date()),
to: new Date(),
},
],
true,
'screen',
revision,
),
);
if (id && id !== BOUND_EXPIRED) {
await dispatch({ accountId, id, section: 'operations' });
return;
}
} catch {
// A failed dispatch has no durable step row; record the problem.
}
dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'operations',
});
}
private async dispatchEvents(
userId: string,
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
) {
try {
const ids = await this.withBound(this.events.refresh(userId, accountId));
if (ids !== BOUND_EXPIRED) {
for (const id of ids) {
await dispatch({ accountId, id, section: 'events' });
}
return;
}
} catch {
// A failed dispatch has no durable step row; record the problem.
}
dispatchProblems.push({
accountId,
errorCode: TbankErrorCode.Unavailable,
section: 'events',
});
}
private async settle(
attempt: TbankSyncAttempt,
cause: HourlyCause | null = null,
dispatchProblems: HourlySection[] = [],
) {
// A suppressed settle error is not lost: the walk leaves the active set
// and the next tick closes the attempt from its durable step results.
await this.repository
.settleHourly(attempt, cause, dispatchProblems)
.catch(() => {
this.logger.event('error', 'tbank.hourly.settle_failed', {
userId: attempt.userId,
});
return undefined;
});
}
// Drops settled and missing rows from the wait set and expires waits that
// passed the per-step bound.
private advanceWaiting(
waiting: Map<string, number>,
rows: { id: string; state: string }[],
) {
for (const row of rows) {
if (row.state !== 'running') waiting.delete(row.id);
}
for (const id of [...waiting.keys()]) {
if (!rows.some((row) => row.id === id)) {
waiting.delete(id);
continue;
}
const startedAt = waiting.get(id) ?? NO_MILLISECONDS;
if (Date.now() - startedAt >= WORK_WAIT_BOUND_MS) waiting.delete(id);
}
}
// Waits for every dispatched step to leave the running state. Each wait is
// bounded so a stuck step cannot hold the attempt running forever; the
// bound does not cancel the upstream call, and late results stay in the
// step row where the attempt fence decides whether they count.
private async waitWorks(userId: string, ids: string[]) {
const waiting = new Map(ids.map((id) => [id, Date.now()]));
let pollMs = WORK_POLL_MS;
while (waiting.size) {
const rows = await this.repository.works(userId, [...waiting.keys()]);
this.advanceWaiting(waiting, rows);
if (waiting.size) {
await new Promise((resolve) => setTimeout(resolve, pollMs));
pollMs = Math.min(pollMs * WORK_POLL_BACKOFF_FACTOR, WORK_POLL_MAX_MS);
}
}
}
}
function monthStartOf(now: Date) {
return new Date(
Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), FIRST_DAY_OF_MONTH),
);
}
@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TbankSyncRepository } from './tbank-sync.repository';
@Module({
providers: [TbankSyncRepository],
exports: [TbankSyncRepository],
})
export class TbankSyncRepositoryModule {}
@@ -1,22 +1,8 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { import {
BadRequestException,
Body,
Controller,
Get,
HttpCode,
HttpStatus,
Param,
Post,
Req,
UseGuards,
ValidationPipe,
} from '@nestjs/common';
import {
ApiAcceptedResponse,
ApiBearerAuth, ApiBearerAuth,
ApiBody,
ApiExtraModels, ApiExtraModels,
ApiOkResponse, ApiOkResponse,
ApiTags, ApiTags,
@@ -27,9 +13,6 @@ import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { import {
EventsInvestmentScreenDto, EventsInvestmentScreenDto,
IncomeInvestmentScreenDto, IncomeInvestmentScreenDto,
InvestmentScreenDto,
investmentScreenSchema,
TbankSyncAcceptedDto,
TbankSyncStatusDto, TbankSyncStatusDto,
TimestampInvestmentScreenDto, TimestampInvestmentScreenDto,
} from './tbank-sync.dto'; } from './tbank-sync.dto';
@@ -37,9 +20,10 @@ import { TbankSyncService } from './tbank-sync.service';
@ApiTags('tbank-sync') @ApiTags('tbank-sync')
@ApiExtraModels( @ApiExtraModels(
IncomeInvestmentScreenDto,
EventsInvestmentScreenDto, EventsInvestmentScreenDto,
IncomeInvestmentScreenDto,
TimestampInvestmentScreenDto, TimestampInvestmentScreenDto,
TbankSyncStatusDto,
) )
@ApiProblemResponses() @ApiProblemResponses()
@ApiBearerAuth() @ApiBearerAuth()
@@ -47,28 +31,6 @@ import { TbankSyncService } from './tbank-sync.service';
@Controller('tbank-sync') @Controller('tbank-sync')
export class TbankSyncController { export class TbankSyncController {
constructor(private readonly sync: TbankSyncService) {} constructor(private readonly sync: TbankSyncService) {}
@Post()
@HttpCode(HttpStatus.ACCEPTED)
@ApiBody({ schema: investmentScreenSchema })
@ApiAcceptedResponse({ type: TbankSyncAcceptedDto })
request(
@Req() request: Request & { user: AccessPayload },
@Body(
new ValidationPipe({
forbidUnknownValues: true,
expectedType: InvestmentScreenDto,
transform: true,
whitelist: true,
forbidNonWhitelisted: true,
validationError: { target: false, value: false },
exceptionFactory: (message) => new BadRequestException({ message }),
}),
)
input: unknown,
) {
return this.sync.request(request.user.sub, input as InvestmentScreenDto);
}
@Get(':id') @Get(':id')
@ApiOkResponse({ type: TbankSyncStatusDto }) @ApiOkResponse({ type: TbankSyncStatusDto })
status( status(
@@ -22,6 +22,7 @@ import {
} from 'class-validator'; } from 'class-validator';
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
import { InvestmentAttemptProblemDto } from '../investment-resource.dto';
import { operationCategories } from '../operations/operations.api'; import { operationCategories } from '../operations/operations.api';
const MIN_ACCOUNT_ID_LENGTH = 1; const MIN_ACCOUNT_ID_LENGTH = 1;
@@ -208,13 +209,6 @@ export const investmentScreenSchema = {
}, },
}; };
export class TbankSyncAcceptedDto {
@ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema })
declare scope: InvestmentScreenDto;
@ApiProperty({ format: 'date-time' }) declare now: string;
}
export class TbankSyncStatusDto { export class TbankSyncStatusDto {
@ApiProperty({ format: 'uuid' }) declare id: string; @ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
@@ -227,3 +221,26 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode: @ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string; null | string;
} }
export class HourlySyncStatusDto {
@ApiProperty({
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
description:
'Last hourly walk state. Cancelled means an interrupted attempt; its start still delays the next term.',
})
declare state:
| 'cancelled' |
'failed' |
'partial' |
'running' |
'succeeded';
@ApiProperty({ format: 'date-time' })
declare startedAt: string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare finishedAt: null | string;
@ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true })
declare problems: InvestmentAttemptProblemDto[];
}
@@ -5,8 +5,10 @@ import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { OperationsModule } from '../operations/operations.module'; import { OperationsModule } from '../operations/operations.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
import { TbankSyncController } from './tbank-sync.controller'; import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncRepository } from './tbank-sync.repository';
import { TbankSyncService } from './tbank-sync.service'; import { TbankSyncService } from './tbank-sync.service';
@Module({ @Module({
imports: [ imports: [
@@ -15,8 +17,11 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule, PortfolioModule,
OperationsModule, OperationsModule,
InstrumentSourcesModule, InstrumentSourcesModule,
UpcomingEventsModule,
TbankSyncRepositoryModule,
], ],
controllers: [TbankSyncController], controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository], providers: [TbankSyncService, TbankHourlyScheduler],
exports: [TbankHourlyScheduler],
}) })
export class TbankSyncModule {} export class TbankSyncModule {}
@@ -3,6 +3,7 @@ import { 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 type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api';
import type { InvestmentAttemptDto } from '../investment-resource.dto';
import type { InvestmentScreenDto } from './tbank-sync.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
@@ -12,6 +13,62 @@ export type ScreenProgress = {
children: string[]; children: string[];
dispatched: boolean; dispatched: boolean;
}; };
export type HourlySection = {
accountId?: string;
errorCode: null | string;
section: 'events' | 'membership' | 'operations' | 'portfolio';
};
export type HourlyChild = {
accountId?: string;
id: string;
section: HourlySection['section'];
};
export const HOURLY_CAUSE_DEADLINE = 'DEADLINE';
export const HOURLY_CAUSE_INTERRUPTED = 'INTERRUPTED';
export type HourlyCause = typeof HOURLY_CAUSE_INTERRUPTED;
type StepOutcome = { problem?: HourlySection; success: boolean };
type StepRow = { attemptId: string; errorCode: null | string; state: string };
export type HourlyProgress = {
attempts?: Record<string, string>;
children?: HourlyChild[];
result?: 'failed' | 'partial' | 'succeeded';
sections: HourlySection[];
};
type HourlyAttempt = {
finishedAt: Date | null;
sections: HourlySection[];
startedAt: Date;
state: 'cancelled' | 'failed' | 'partial' | 'running' | 'succeeded';
};
export function hourlyAttempt(
hourly: HourlyAttempt | null,
section: HourlySection['section'] | readonly HourlySection['section'][],
accountId?: string,
): InvestmentAttemptDto {
if (!hourly) {
return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
}
const sections = Array.isArray(section) ? section : [section];
const problems = hourly.sections
.filter(
(entry) =>
sections.includes(entry.section) &&
(accountId === undefined || entry.accountId === accountId),
)
.map((entry) => ({
section: entry.section,
accountId: entry.accountId ?? null,
errorCode: entry.errorCode,
}));
return {
state: hourly.state === 'cancelled' ? 'failed' : hourly.state,
startedAt: hourly.startedAt.toISOString(),
finishedAt: hourly.finishedAt?.toISOString() ?? null,
problems,
};
}
@Injectable() @Injectable()
export class TbankSyncRepository { export class TbankSyncRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
@@ -123,4 +180,248 @@ export class TbankSyncRepository {
}); });
}); });
} }
// The timer only proposes work; this reservation is the durable fence. The
// attempt start (createdAt) is the basis of the next hourly term, including
// failed and interrupted attempts. After downtime the next tick is due at
// once, so no catch-up queue forms.
reserveHourly(userId: string, runId: string, hourMs: number) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findFirst({
where: { userId, invalidToken: false },
select: { revision: true },
});
if (!connection) return null;
const key = {
userId,
revision: connection.revision,
scopeKey: 'hourly',
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: key },
});
const now = new Date();
if (previous) {
// Terms are hourMs steps from the attempt start; a walk still running
// at a term consumes it. A dead process's attempt has no known end, so
// it consumes only its own term.
const started = previous.createdAt.valueOf();
const ended =
previous.state === 'running' ? started : previous.updatedAt.valueOf();
const dueAt =
started +
Math.max(hourMs, Math.ceil((ended - started) / hourMs) * hourMs);
const due = now.valueOf() >= dueAt;
if (previous.state === 'running') {
if (previous.processRunId === runId) return null;
// A dead process cannot finish its attempt; close it from its
// actual step results, but its start stays the basis of the next
// term.
await this.closeHourly(tx, previous, 'INTERRUPTED', []);
if (!due) return null;
} else if (!due) return null;
}
const data = {
attemptId: randomUUID(),
processRunId: runId,
state: 'running',
priority: 0,
intent: JSON.parse(
JSON.stringify({ source: 'hourly' }),
) as Prisma.InputJsonObject,
progress: { sections: [] } as unknown as Prisma.InputJsonValue,
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
where: { userId_revision_scopeKey: key },
create: { ...key, ...data, id: randomUUID() },
update: { ...data, createdAt: now },
});
return { work, launch: true };
});
}
private classifyStep(
child: HourlyChild,
row: StepRow | undefined,
recorded: string | undefined,
cause: HourlyCause | null,
): StepOutcome {
if (row && row.attemptId === recorded && row.state === 'complete') {
return { success: true };
}
const stale = !row || (recorded !== undefined && row.attemptId !== recorded);
let errorCode: null | string = null;
if (!stale && row.state === 'failed') errorCode = row.errorCode;
else if (!stale && cause === HOURLY_CAUSE_INTERRUPTED) {
errorCode = HOURLY_CAUSE_INTERRUPTED;
} else if (!stale) errorCode = HOURLY_CAUSE_DEADLINE;
return {
problem: { accountId: child.accountId, errorCode, section: child.section },
success: false,
};
}
private deriveResult(
cause: HourlyCause | null,
sections: HourlySection[],
successes: number,
): 'failed' | 'partial' | 'succeeded' {
if (!cause && !sections.length) return 'succeeded';
return successes ? 'partial' : 'failed';
}
// The outcome is derived from the durable state of the dispatched steps,
// never from an in-memory error list that an exception path can empty.
// `cause` marks a close before normal completion and caps the result at
// `partial`; `dispatchProblems` records required sections that never got a
// durable step row because their dispatch failed.
private async closeHourly(
tx: Prisma.TransactionClient,
work: {
attemptId: string;
id: string;
progress: Prisma.JsonValue;
revision: string;
userId: string;
},
cause: HourlyCause | null,
dispatchProblems: HourlySection[],
) {
const progress = work.progress as HourlyProgress;
const children = progress.children ?? [];
const rows = children.length
? await tx.tbankSyncWork.findMany({
where: {
userId: work.userId,
id: { in: children.map((child) => child.id) },
},
select: { attemptId: true, errorCode: true, id: true, state: true },
})
: [];
const byId = new Map(rows.map((row) => [row.id, row]));
const outcomes = children.map((child) =>
this.classifyStep(
child,
byId.get(child.id),
progress.attempts?.[child.id],
cause,
),
);
const sections = outcomes
.flatMap((outcome) => (outcome.problem ? [outcome.problem] : []))
.concat(dispatchProblems);
const successes = outcomes.filter((outcome) => outcome.success).length;
const result = this.deriveResult(cause, sections, successes);
return tx.tbankSyncWork.updateMany({
where: { id: work.id, attemptId: work.attemptId, state: 'running' },
data: {
state: result === 'failed' ? 'failed' : 'complete',
errorCode:
cause ??
sections.find((section) => section.errorCode)?.errorCode ??
null,
progress: {
attempts: progress.attempts ?? {},
children,
result,
sections,
},
},
});
}
async settleHourly(
attempt: TbankSyncAttempt,
cause: HourlyCause | null = null,
dispatchProblems: HourlySection[] = [],
) {
return this.prisma.$transaction(async (tx) => {
const work = await tx.tbankSyncWork.findFirst({ where: attempt });
if (!work || work.state !== 'running') return { count: 0 };
return this.closeHourly(tx, work, cause, dispatchProblems);
});
}
// Records a dispatched step on the attempt before it is awaited, so a lost
// walk can still derive its outcome from durable rows.
async recordHourlyChild(attempt: TbankSyncAttempt, child: HourlyChild) {
return this.prisma.$transaction(async (tx) => {
const work = await tx.tbankSyncWork.findFirst({
where: { ...attempt, state: 'running' },
select: { progress: true },
});
if (!work) return false;
const progress = work.progress as HourlyProgress;
const row = await tx.tbankSyncWork.findFirst({
where: { userId: attempt.userId, id: child.id },
select: { attemptId: true },
});
const updated = await tx.tbankSyncWork.updateMany({
where: { ...attempt, state: 'running' },
data: {
progress: {
attempts: {
...progress.attempts,
...(row ? { [child.id]: row.attemptId } : {}),
},
children: [...(progress.children ?? []), child],
sections: progress.sections ?? [],
},
},
});
return Boolean(updated.count);
});
}
runningHourly(userId: string) {
return this.prisma.tbankSyncWork.findMany({
where: { userId, scopeKey: 'hourly', state: 'running' },
select: { attemptId: true, id: true, processRunId: true, revision: true },
});
}
async hourly(userId: string) {
const connection = await this.prisma.tbankConnection.findFirst({
where: { userId },
select: { revision: true, invalidToken: true },
});
if (!connection || connection.invalidToken) return null;
const work = await this.prisma.tbankSyncWork.findUnique({
where: {
userId_revision_scopeKey: {
userId,
revision: connection.revision,
scopeKey: 'hourly',
},
},
});
if (!work) return null;
const progress = work.progress as HourlyProgress;
let state: 'cancelled' | 'running' | HourlyProgress['result'];
if (work.state === 'running') state = 'running';
else if (work.state === 'cancelled') state = 'cancelled';
else state = progress.result ?? 'failed';
return {
startedAt: work.createdAt,
finishedAt: work.state === 'running' ? null : work.updatedAt,
state,
sections: progress.sections ?? [],
};
}
works(userId: string, ids: string[]) {
return this.prisma.tbankSyncWork.findMany({
where: { userId, id: { in: ids } },
select: { id: true, state: true, errorCode: true },
});
}
async workError(userId: string, id: string) {
const work = await this.prisma.tbankSyncWork.findFirst({
where: { userId, id },
select: { state: true, errorCode: true },
});
return work?.state === 'failed' ? work.errorCode : null;
}
} }
@@ -2,315 +2,27 @@ import {
ConflictException, ConflictException,
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { import { TbankSyncStatusDto } from './tbank-sync.dto';
InstrumentMetadataService,
TbankAccessService,
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import {
CandleService,
CatalogDiscoveryService,
CatalogEnrichmentService,
} from '../instruments/instruments.api';
import { planInvestmentScreen } from '../investment-sync-plan';
import {
BrokerageOperationsSyncService,
type OperationInterval,
} from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { normalizeScreen } from './tbank-screen';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import { import {
type ScreenIntent, type ScreenIntent,
TbankSyncRepository, TbankSyncRepository,
} from './tbank-sync.repository'; } from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0;
@Injectable() @Injectable()
export class TbankSyncService { export class TbankSyncService {
private readonly runId = randomUUID(); private readonly runId = randomUUID();
private readonly startedAt = new Date(); private readonly startedAt = new Date();
private reservations: Promise<unknown> = Promise.resolve();
constructor( constructor(
private readonly repository: TbankSyncRepository, private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService, private readonly operations: BrokerageOperationsSyncService,
private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService,
) {} ) {}
async request(userId: string, input: InvestmentScreenDto) {
const scope = normalizeScreen(input);
const connection = await this.access.local(userId, scope.accountId);
const reservation = this.reservations.then(() =>
this.repository.reserve(userId, connection.revision, scope, this.runId),
);
this.reservations = reservation.catch(() => undefined);
const reserved = await reservation;
if (!reserved) throw new ServiceUnavailableException();
const intent = reserved.work.intent as unknown as ScreenIntent;
if (reserved.launch) {
const attempt = {
userId,
revision: connection.revision,
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
setImmediate(() => {
void this.dispatch(attempt, intent);
});
}
return { id: reserved.work.id, ...intent };
}
private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) {
const children: string[] = [];
const add = async (ids: (null | string | undefined)[]) => {
children.push(...ids.filter((id): id is string => Boolean(id)));
await this.repository.progress(attempt, children, false);
};
try {
const { userId, revision } = attempt;
const { scope } = intent;
const now = new Date(intent.now);
const member = await this.membership.request(userId, revision, true);
await add([member?.id]);
await member?.done;
const current = await this.access.local(userId, scope.accountId);
if (current.revision !== revision) return;
const accountScope = scope.accountId ?? 'all';
const selectedAccounts = current.accounts.filter(
(account) =>
accountScope === 'all' || account.accountId === accountScope,
);
if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(
scope.screen,
)
) {
for (const account of scope.screen === 'stock' ||
scope.screen === 'bond'
? current.accounts
: selectedAccounts) {
const work = await this.portfolio.request(
userId,
revision,
account.accountId,
true,
);
await add([work?.id]);
await work?.done;
}
}
const saved = await this.portfolio.read(
userId,
scope.screen === 'stock' || scope.screen === 'bond'
? 'all'
: accountScope,
true,
);
const positions = saved.data?.positions ?? [];
const instruments = [
...new Map(
positions.flatMap((position) =>
position.instrumentUid &&
(position.type === 'STOCK' || position.type === 'BOND')
? [
[
position.instrumentUid,
{
uid: position.instrumentUid,
type: position.type,
historyFrom: null,
},
] as const,
]
: [],
),
).values(),
];
const plan = planInvestmentScreen(
{
...scope,
candleRange: scope.candleRange
? {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
}
: undefined,
},
{
accounts: selectedAccounts,
instruments:
scope.screen === 'stocks' || scope.screen === 'bonds'
? []
: instruments,
membershipKnown: Boolean(current.membershipAt),
},
now,
true,
);
const intervals: OperationInterval[] = plan.sources.flatMap((source) =>
source.kind === 'operations' &&
source.accountId &&
source.from &&
source.to
? [
{
accountId: source.accountId,
from: source.from,
to: source.to,
...(source.instrumentUid
? { instrumentUid: source.instrumentUid }
: {}),
},
]
: [],
);
if (intervals.length) {
await add([
await this.operations.request(
userId,
intervals,
true,
'screen',
revision,
),
]);
}
for (const source of plan.sources) {
const uid = source.instrumentUid;
const type = source.instrumentType === 'BOND' ? 'bond' : 'share';
if (source.kind === 'catalog') {
await add(
await this.discovery.refresh(userId, revision, {
instrumentType: type,
query: source.query ?? '',
}),
);
}
if (!uid) continue;
if (source.kind === 'metadata') {
await add([
await this.metadata.request(userId, revision, {
instrumentUid: uid,
}),
]);
}
if (source.kind === 'quote') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'bond' ? 'bond-current' : 'detail',
'last-price',
'trading-status',
'comparison',
],
),
);
}
if (source.kind === 'company') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['description', 'fundamentals'],
),
);
}
if (source.kind === 'metrics') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['coupons', 'yield', 'rating'],
),
);
}
if (source.kind === 'calendar' && source.from && source.to) {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'share'
? 'dividends'
: scope.screen === 'bond'
? 'coupons'
: 'bond-events',
],
{
from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
ticker:
positions.find((position) => position.instrumentUid === uid)
?.ticker ?? undefined,
},
),
);
}
}
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
await add([
await this.candles.request(
userId,
revision,
{
uid: scope.instrumentUid,
instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
now,
true,
FOREGROUND_CANDLE_PRIORITY,
),
]);
}
await this.repository.progress(attempt, children, true);
} catch {
await this.repository
.progress(attempt, children, true, TbankErrorCode.Unavailable)
.catch(() => undefined);
}
}
async status(userId: string, id: string): Promise<TbankSyncStatusDto> { async status(userId: string, id: string): Promise<TbankSyncStatusDto> {
const connection = await this.access const connection = await this.access
.local(userId) .local(userId)
@@ -4,11 +4,12 @@ import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { UpcomingEventsController } from './upcoming-events.controller'; import { UpcomingEventsController } from './upcoming-events.controller';
import { UpcomingEventsService } from './upcoming-events.service'; import { UpcomingEventsService } from './upcoming-events.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule], imports: [AuthModule, TbankModule, InstrumentSourcesModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [UpcomingEventsController], controllers: [UpcomingEventsController],
providers: [UpcomingEventsService], providers: [UpcomingEventsService],
exports: [UpcomingEventsService], exports: [UpcomingEventsService],
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import { import {
add, add,
type Decimal,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
isZero, isZero,
@@ -20,6 +21,10 @@ import {
type PortfolioPositionDto, type PortfolioPositionDto,
PortfolioService, PortfolioService,
} from '../portfolio/portfolio.api'; } from '../portfolio/portfolio.api';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../tbank-sync/tbank-sync.repository';
import { bondEvent, dividendEvent } from './upcoming-event.mapper'; import { bondEvent, dividendEvent } from './upcoming-event.mapper';
import { import {
UpcomingEventDto, UpcomingEventDto,
@@ -43,66 +48,28 @@ export class UpcomingEventsService {
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly sync: TbankSyncRepository,
) {} ) {}
// Read endpoints stay local; callers without a saved portfolio receive unknown.
async get( async get(
userId: string, userId: string,
accountId: string, accountId: string,
query: UpcomingEventsQueryDto = {}, query: UpcomingEventsQueryDto = {},
) { ): Promise<Awaited<ReturnType<ForecastService['result']>>> {
const window = dateWindow(query); const window = dateWindow(query);
const connection = await this.access.local(userId, accountId); const connection = await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read( const portfolio = await this.portfolios.read(userId, accountId);
userId, const hourly = await this.sync.hourly(userId);
accountId,
query.sync === 'observe',
);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const events: UpcomingEventDto[] = []; const events: UpcomingEventDto[] = [];
let ready = portfolio.data !== null; let ready = portfolio.data !== null;
const positions = portfolio.data?.positions.filter(supported) ?? []; const positions = portfolio.data?.positions.filter(supported) ?? [];
const metadata = await this.metadata.savedMany(positions); const { sources, metadata } = await this.readSources(
for (const type of ['share', 'bond'] as const) { userId,
const identities = positions positions,
.filter( window,
(position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'), true,
) );
.map((position) => ({
uid:
position.instrumentUid ||
metadata.get(metadataKey(position))?.data?.uid,
ticker:
position.ticker ||
metadata.get(metadataKey(position))?.data?.ticker ||
'',
}))
.filter((entry): entry is { ticker: string; uid: string } =>
Boolean(entry.uid),
);
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += calendarReadBatchSize
) {
sources.push(
...(await this.sources.readMany(
userId,
type,
uids.slice(offset, offset + calendarReadBatchSize),
query.sync === 'observe',
['detail', type === 'share' ? 'dividends' : 'bond-events'],
{
...window,
tickers: Object.fromEntries(
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
)),
);
}
}
for (const position of positions) { for (const position of positions) {
const generic = metadata.get(metadataKey(position))?.data; const generic = metadata.get(metadataKey(position))?.data;
const uid = position.instrumentUid || generic?.uid; const uid = position.instrumentUid || generic?.uid;
@@ -138,7 +105,7 @@ export class UpcomingEventsService {
source.input.uid === uid && source.input.kind === 'detail', source.input.uid === uid && source.input.kind === 'detail',
)?.instrument || generic; )?.instrument || generic;
const ticker = position.ticker || instrument?.ticker; const ticker = position.ticker || instrument?.ticker;
if (!calendar || calendar.meta.availability !== 'complete' || !ticker) { if (!calendar || calendar.meta.completeness !== 'complete' || !ticker) {
ready = false; ready = false;
continue; continue;
} }
@@ -197,6 +164,11 @@ export class UpcomingEventsService {
membershipRevision: connection.membershipRevision, membershipRevision: connection.membershipRevision,
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`, scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
portfolio: portfolio.meta, portfolio: portfolio.meta,
lastAttempt: hourlyAttempt(
hourly,
'events',
accountId === 'all' ? undefined : accountId,
),
sources, sources,
candidate, candidate,
}); });
@@ -205,6 +177,89 @@ export class UpcomingEventsService {
getAll(userId: string, query: UpcomingEventsQueryDto = {}) { getAll(userId: string, query: UpcomingEventsQueryDto = {}) {
return this.get(userId, 'all', query); return this.get(userId, 'all', query);
} }
// Triggers online refreshes of the account's event sources and returns the
// launched work ids for the hourly walk to observe.
async refresh(
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
): Promise<string[]> {
const window = dateWindow(query);
await this.access.local(userId, accountId);
const portfolio = await this.portfolios.read(userId, accountId);
const positions = portfolio.data?.positions.filter(supported) ?? [];
const { launched } = await this.readSources(
userId,
positions,
window,
false,
);
return launched;
}
private async readSources(
userId: string,
positions: Position[],
window: { from: string; to: string },
observe: boolean,
) {
const metadata = await this.metadata.savedMany(positions);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const launched: string[] = [];
for (const type of ['share', 'bond'] as const) {
const identities = this.positionIdentities(positions, metadata, type);
const uids = [...new Set(identities.map((entry) => entry.uid))];
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += calendarReadBatchSize
) {
const batch = await this.sources.readMany(
userId,
type,
uids.slice(offset, offset + calendarReadBatchSize),
observe,
['detail', type === 'share' ? 'dividends' : 'bond-events'],
{
...window,
tickers: Object.fromEntries(
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
);
sources.push(...batch);
for (const source of batch) {
if (source.meta.sync.state === 'running' && source.meta.sync.id) {
launched.push(source.meta.sync.id);
}
}
}
}
return { sources, metadata, launched };
}
private positionIdentities(
positions: Position[],
metadata: Awaited<ReturnType<InstrumentMetadataService['savedMany']>>,
type: 'bond' | 'share',
) {
return positions
.filter((position) => position.type === (type === 'share' ? 'STOCK' : 'BOND'))
.map((position) => ({
uid:
position.instrumentUid ||
metadata.get(metadataKey(position))?.data?.uid,
ticker:
position.ticker ||
metadata.get(metadataKey(position))?.data?.ticker ||
'',
}))
.filter((entry): entry is { ticker: string; uid: string } =>
Boolean(entry.uid),
);
}
} }
function supported(position: Position): boolean { function supported(position: Position): boolean {
@@ -226,7 +281,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
} }
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimalFromString>>(); const totals = new Map<string, Decimal>();
for (const event of events) { for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) { if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
continue; continue;
+12 -1
View File
@@ -3,9 +3,20 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
export const API_PREFIX = 'api/v1'; export const API_PREFIX = 'api/v1';
// Nest Swagger cannot express additionalProperties on a class, so closed request
// bodies are restored here after generation; the runtime allowlist stays in the controller.
const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto'];
export function createOpenApiDocument(app: INestApplication) { export function createOpenApiDocument(app: INestApplication) {
return SwaggerModule.createDocument( const document = SwaggerModule.createDocument(
app, app,
new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(), new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(),
); );
const schemas = document.components?.schemas ?? {};
for (const [name, schema] of Object.entries(schemas)) {
if (CLOSED_SCHEMAS.includes(name) && 'properties' in schema) {
schema.additionalProperties = false;
}
}
return document;
} }
+4 -1
View File
@@ -9,6 +9,7 @@ import { AppModule } from '../src/app.module';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client'; import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main'; import { configureApp } from '../src/main';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
describe('All accounts HTTP boundary', () => { describe('All accounts HTTP boundary', () => {
@@ -31,6 +32,8 @@ describe('All accounts HTTP boundary', () => {
const module = await Test.createTestingModule({ imports: [AppModule] }) const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient) .overrideProvider(TbankClient)
.useValue(upstream) .useValue(upstream)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile(); .compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
@@ -81,7 +84,7 @@ describe('All accounts HTTP boundary', () => {
expect(response.body).not.toHaveProperty('token'); expect(response.body).not.toHaveProperty('token');
expect(response.body).not.toHaveProperty('generation'); expect(response.body).not.toHaveProperty('generation');
expect(response.body).toMatchObject({ expect(response.body).toMatchObject({
meta: { availability: 'complete' }, meta: { availability: 'available', completeness: 'complete' },
...(path === 'portfolio' ? { data: { positions: [] } } : {}), ...(path === 'portfolio' ? { data: { positions: [] } } : {}),
}); });
}, },
+245 -14
View File
@@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import { createHash, randomUUID } from 'node:crypto';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
@@ -10,6 +12,7 @@ import { AppModule } from '../src/app.module';
import { loggingOptions } from '../src/common/logging/logging.module'; import { loggingOptions } from '../src/common/logging/logging.module';
import { configureApp } from '../src/main'; import { configureApp } from '../src/main';
import { AuthService } from '../src/modules/auth/application/auth.service'; import { AuthService } from '../src/modules/auth/application/auth.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
type Authentication = { accessToken: string; user: { email: string } }; type Authentication = { accessToken: string; user: { email: string } };
@@ -29,11 +32,15 @@ describe('Authentication (e2e)', () => {
pinoHttp: { pinoHttp: {
...loggingOptions(config), ...loggingOptions(config),
level: 'info', level: 'info',
transport: undefined,
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) }, stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
}, },
assignResponse: true, assignResponse: true,
}), }),
}).compile(); })
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
configureApp(app); configureApp(app);
@@ -90,6 +97,171 @@ describe('Authentication (e2e)', () => {
await agent.post('/api/v1/auth/logout').expect(204); await agent.post('/api/v1/auth/logout').expect(204);
}); });
it('rejects a valid refresh token as Bearer while its session stays usable', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'bearer@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const refreshToken = /^refresh_token=([^;]+)/.exec(cookie)?.[1];
if (!refreshToken) throw new Error('Expected a refresh token cookie');
// Signed, unexpired and backed by a live session: only its type is wrong.
await expect(app.get(JwtService).verifyAsync(refreshToken)).resolves.toMatchObject({
type: 'refresh',
});
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${refreshToken}`)
.expect(401)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }),
);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
describe('refresh cookie policy', () => {
const attributes = (cookie: string) =>
cookie
.split('; ')
.slice(1)
.filter((attribute) => !attribute.startsWith('Expires='))
.sort((a, b) => a.localeCompare(b));
const host = ['HttpOnly', 'Path=/api/v1/auth', 'SameSite=Lax'];
// Persistent for the Session lifetime (7 days), otherwise the browser drops it on restart.
const persistent = [...host, 'Max-Age=604800'];
const sorted = (items: string[]) => [...items].sort((a, b) => a.localeCompare(b));
const login = async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'cookie@example.test', password: 'a secure password' })
.expect(200);
return registration.headers['set-cookie'][0];
};
// The cookie policy reads NODE_ENV per request; the e2e runner defaults to development.
const inEnvironment = async <T>(nodeEnv: string, run: () => Promise<T>) => {
const previous = process.env['NODE_ENV'];
process.env['NODE_ENV'] = nodeEnv;
try {
return await run();
} finally {
process.env['NODE_ENV'] = previous;
}
};
it('issues and rotates it persistent (Max-Age) HttpOnly, Lax, path-scoped, host-only and Secure in production, and clears it', async () => {
await inEnvironment('production', async () => {
const issued = await login();
const rotated = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', issued)
.expect(200);
const cleared = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', rotated.headers['set-cookie'][0])
.expect(204);
// Exact set: any added Domain, changed Max-Age or dropped flag fails.
for (const cookie of [issued, rotated.headers['set-cookie'][0]]) {
expect(attributes(cookie)).toEqual(sorted([...persistent, 'Secure']));
}
// Clearing must not carry Max-Age; Express expires it with a past Expires.
const clearedCookie = cleared.headers['set-cookie'][0];
expect(attributes(clearedCookie)).toEqual(sorted([...host, 'Secure']));
expect(clearedCookie).toContain('Expires=Thu, 01 Jan 1970');
});
});
it('drops only Secure in development and keeps every other attribute', async () => {
await inEnvironment('development', async () => {
expect(attributes(await login())).toEqual(sorted(persistent));
});
});
});
describe('expired credentials', () => {
const sha256 = (token: string) => createHash('sha256').update(token).digest('hex');
const PAST = () => Math.floor(Date.now() / 1000) - 60;
const FUTURE = () => Math.floor(Date.now() / 1000) + 600;
const register = async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'expiry@example.test', password: 'a secure password' })
.expect(200);
const session = await app.get(PrismaService).session.findFirstOrThrow();
return {
cookie: registration.headers['set-cookie'][0],
sub: session.userId,
sid: session.id,
};
};
it('rejects a signed expired access token but admits the same claims unexpired', async () => {
const { sub, sid } = await register();
const jwt = app.get(JwtService);
const expired = await jwt.signAsync({ sub, sid, type: 'access', exp: PAST() });
const fresh = await jwt.signAsync({ sub, sid, type: 'access', exp: FUTURE() });
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${expired}`)
.expect(401);
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${fresh}`)
.expect(200)
.expect(({ body }: { body: unknown }) =>
expect((body as { email: string }).email).toBe('expiry@example.test'),
);
});
it('rejects a signed expired refresh token that matches the live session hash', async () => {
const { sub, sid } = await register();
const jwt = app.get(JwtService);
const prisma = app.get(PrismaService);
const claims = { sub, sid, type: 'refresh' };
const expired = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: PAST() });
const fresh = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: FUTURE() });
// Hash, session and signature all valid: only `exp` differs between the two.
await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(expired) } });
const rejected = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', `refresh_token=${expired}`)
.expect(401);
expect(rejected.headers['set-cookie']).toBeUndefined();
await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(fresh) } });
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', `refresh_token=${fresh}`)
.expect(200);
});
it('rejects a valid refresh token once its session is DB-expired', async () => {
const { cookie, sid } = await register();
const prisma = app.get(PrismaService);
await prisma.session.update({ where: { id: sid }, data: { expiresAt: new Date(Date.now() - 1000) } });
const rejected = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(401);
expect(rejected.headers['set-cookie']).toBeUndefined();
expect(await prisma.session.count()).toBe(0);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(401);
});
});
it('keeps logout idempotent for an invalid refresh token', async () => { it('keeps logout idempotent for an invalid refresh token', async () => {
const logout = await request(app.getHttpServer()) const logout = await request(app.getHttpServer())
.post('/api/v1/auth/logout') .post('/api/v1/auth/logout')
@@ -261,6 +433,45 @@ describe('Authentication (e2e)', () => {
expect(await app.get(PrismaService).user.count()).toBe(0); expect(await app.get(PrismaService).user.count()).toBe(0);
}); });
it('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => {
const validation = (detail: string) => ({
status: 400,
code: 'VALIDATION_ERROR',
errors: [{ pointer: '#/password', detail }],
});
for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) {
const credentials = { email: `user${index}@example.com`, password };
await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200);
await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200);
}
for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) {
await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'rejected@example.com', password })
.expect(400)
.expect('Content-Type', /application\/problem\+json/)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual(validation('must be 8-72 printable ASCII characters')),
);
}
expect(await app.get(PrismaService).user.count()).toBe(2);
// An account created under the previous policy (Cyrillic, 12+ chars) must still log in.
const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' };
const prisma = app.get(PrismaService);
await prisma.user.create({
data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) },
});
await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: legacy.email, password: 'a'.repeat(73) })
.expect(400)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')),
);
});
it('guards T-Bank connection and never returns a token', async () => { it('guards T-Bank connection and never returns a token', async () => {
const registration = await request(app.getHttpServer()) const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register') .post('/api/v1/auth/register')
@@ -329,7 +540,14 @@ describe('Authentication (e2e)', () => {
'X-Request-ID', 'X-Request-ID',
); );
expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([ expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }), expect.objectContaining({
level: 40,
message: 'http.request.completed',
method: 'GET',
route: '/api/v1/auth/me',
status: 401,
code: 'UNAUTHORIZED',
}),
]); ]);
}); });
@@ -341,7 +559,7 @@ describe('Authentication (e2e)', () => {
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'), new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
); );
const response = await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/v1/auth/login') .post('/api/v1/auth/login')
.set('X-Request-ID', clientRequestId) .set('X-Request-ID', clientRequestId)
.send({ email: 'user@example.com', password: 'a secure password' }) .send({ email: 'user@example.com', password: 'a secure password' })
@@ -351,17 +569,30 @@ describe('Authentication (e2e)', () => {
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
); );
const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']); expect(logs).toEqual(
expect(completed).toEqual([ expect.arrayContaining([
expect.objectContaining({ expect.objectContaining({
requestId: response.headers['x-request-id'], method: 'POST',
clientRequestId, route: '/api/v1/auth/login',
status: 500, status: 500,
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
level: 50, level: 50,
message: 'http.request.completed', message: 'http.request.completed',
}), }),
]); ]),
);
expect(logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
clientRequestId,
code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.unhandled',
errorName: 'Error',
errorStack: expect.stringContaining(' at ') as unknown,
}),
]),
);
expect(JSON.stringify(logs)).not.toContain( expect(JSON.stringify(logs)).not.toContain(
'SYNTHETIC_AUDIT_SECRET', 'SYNTHETIC_AUDIT_SECRET',
); );
+8 -1
View File
@@ -125,7 +125,14 @@ function tbankSnapshot() {
const owner = auth.user.id; const owner = auth.user.id;
await stop(); await stop();
const db = new Database(file); const db = new Database(file);
db.prepare('INSERT INTO TbankConnection (userId,encryptedToken,connectedAt) VALUES (?,?,?)').run(owner, 'fictional-encrypted-token-never-used', new Date().toISOString()); const columns = db.prepare('PRAGMA table_info("TbankConnection")').all().map(({ name }) => name);
const fields = ['userId', 'encryptedToken', 'connectedAt'];
const values = [owner, 'fictional-encrypted-token-never-used', new Date().toISOString()];
if (columns.includes('revision')) {
fields.push('revision');
values.push(randomUUID());
}
db.prepare(`INSERT INTO TbankConnection (${fields.join(',')}) VALUES (${fields.map(() => '?').join(',')})`).run(...values);
await db.backup(join(dir, 'before-migration.db')); await db.backup(join(dir, 'before-migration.db'));
db.close(); db.close();
const beforeSessions = rows('Session'), beforeUsers = rows('User'); const beforeSessions = rows('Session'), beforeUsers = rows('User');
+15 -1
View File
@@ -16,6 +16,7 @@ import {
BankSummaryDto, BankSummaryDto,
ExpenseCategoryDto, ExpenseCategoryDto,
} from '../src/modules/banking/bank-cards/presentation/bank-card.dto'; } from '../src/modules/banking/bank-cards/presentation/bank-card.dto';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
jest.setTimeout(60000); jest.setTimeout(60000);
@@ -77,7 +78,10 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
const start = async () => { const start = async () => {
const module = await Test.createTestingModule({ const module = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).compile(); })
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
await app.listen(0, '127.0.0.1'); await app.listen(0, '127.0.0.1');
@@ -369,6 +373,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
expect((await getCard(own.id)).balance.value).toBe('1000'); expect((await getCard(own.id)).balance.value).toBe('1000');
}); });
it('renames default category without role loss; duplicates and stale rename conflict', async () => { it('renames default category without role loss; duplicates and stale rename conflict', async () => {
// Defaults are seeded by the first write of the user; this case must not depend on earlier cases.
await createCard();
const category = (await categories()).find((c) => c.isDefault); const category = (await categories()).find((c) => c.isDefault);
assert.ok(category); assert.ok(category);
await auth('patch', '/categories/' + category.id) await auth('patch', '/categories/' + category.id)
@@ -725,11 +731,19 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
expect(await getCard(card.id)).toEqual(before); expect(await getCard(card.id)).toEqual(before);
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id); expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
await auth('post', '/operations').send(deletedBody).expect(410); await auth('post', '/operations').send(deletedBody).expect(410);
// The rename is this case's own prerequisite, not an effect of an earlier case.
const defaultCategory = (await categories()).find((c) => c.isDefault);
assert.ok(defaultCategory);
await auth('patch', '/categories/' + defaultCategory.id)
.send({ version: defaultCategory.version, name: 'Остальное' })
.expect(200);
expect((await categories()).find((c) => c.isDefault)?.name).toBe( expect((await categories()).find((c) => c.isDefault)?.name).toBe(
'Остальное', 'Остальное',
); );
}); });
it('enforces actual FK and unique constraints', async () => { it('enforces actual FK and unique constraints', async () => {
// Seeds the owner's default categories so the unique-name check has a real row to collide with.
await createCard();
await expect( await expect(
db.expenseCategory.create({ db.expenseCategory.create({
data: { data: {
+47
View File
@@ -1,5 +1,6 @@
import type { Server } from 'node:http'; import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import { import {
ConflictException, ConflictException,
INestApplication, INestApplication,
@@ -15,6 +16,14 @@ import { configureApp } from '../src/main';
import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service'; import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service'; import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service'; import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import {
bootOnlineApp,
expectSafeBody,
failUpstream,
onlineUserId,
refusals,
} from './online-upstream';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13'; const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Bond HTTP contract', () => { describe('Bond HTTP contract', () => {
@@ -38,6 +47,8 @@ describe('Bond HTTP contract', () => {
.useValue(bonds) .useValue(bonds)
.overrideProvider(BondIncomeService) .overrideProvider(BondIncomeService)
.useValue(bonds) .useValue(bonds)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile(); .compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
@@ -198,3 +209,39 @@ describe('Bond HTTP contract', () => {
expect(response.body).not.toHaveProperty('stack'); expect(response.body).not.toHaveProperty('stack');
}); });
}); });
// Real services and TbankClient error mapping; only the gRPC transport is stubbed.
// Bonds expose no company or screener route; their online entrypoints are search, detail and ticker.
describe('Bond online reads: upstream failure translation', () => {
const rpc = jest.fn();
let ctx: Awaited<ReturnType<typeof bootOnlineApp>>;
const get = (path: string) => request(ctx.app.getHttpServer()).get(path);
// [entrypoint, request, RPC that fails second]: a failure after the first call must translate too.
const entrypoints: [string, () => request.Test, string][] = [
['search', () => get('/api/v1/bonds?query=SBER'), 'getLastPrices'],
['detail', () => get(`/api/v1/bonds/${uid}`), 'bondBy'],
['ticker', () => get('/api/v1/bonds/by-ticker/SBER'), 'bondBy'],
];
const cases = entrypoints.flatMap(([name, send, later]) =>
refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)),
);
beforeAll(async () => {
ctx = await bootOnlineApp(rpc);
});
afterAll(() => ctx.app.close());
it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => {
failUpstream(rpc, 'bond', code, at);
const response = await send().set('Authorization', ctx.bearer).expect(400);
expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN');
const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } });
expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision });
});
it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => {
failUpstream(rpc, 'bond', status.UNAVAILABLE);
const response = await send().set('Authorization', ctx.bearer).expect(503);
expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE');
});
});

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