33 Commits
Author SHA1 Message Date
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
ksv741 03e75f8d2b chore(release): 2.0.0
Backend image / publish (push) Successful in 9m4s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m16s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Successful in 6m13s
2026-10-07 18:14:23 +03:00
ksv741 dbb1748a5f chore: synchronize main history before release 2026-10-07 18:13:12 +03:00
ksv741 01e2de6a04 test: separate performance budget from e2e gate
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 39s
Backend verify / verify (pull_request) Successful in 5m46s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 16s
Backend verify / verify (push) Successful in 6m50s
2026-10-07 16:35:23 +03:00
ksv741 30e96e51e2 fix(deps): restore backend security gate
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 10m22s
2026-10-07 15:19:42 +03:00
ksv741 a7619d5c8f perf(income): compute Moscow dates once
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 12s
Backend verify / verify (pull_request) Successful in 9m53s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 10m3s
2026-10-03 19:44:39 +03:00
ksv741 d698ed9425 refactor!: simplify runtime coordination #7
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 8s
Backend verify / verify (pull_request) Failing after 9m36s
Keep financial ownership, atomic publication and refresh CAS.
Failed refresh leaves cookies untouched; logout still clears them.

Remove full-history candle prefetch and special metadata retries.
Use native Pino request context instead of parallel middleware.

BREAKING CHANGE: remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
2026-10-03 11:38:08 +03:00
ksv741 51f345bdc5 Merge branch 'develop' into main
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 1m46s
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Failing after 10s
Backend verify / verify (pull_request) Skipped
2026-10-02 08:22:31 +03:00
116 changed files with 4507 additions and 2986 deletions
+2 -2
View File
@@ -41,8 +41,8 @@ provider records преобразуются перед чистыми domain cal
Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json` Nest DTO/metadata → `npm run openapi:generate` → игнорируемый Git `openapi.json`
для публичных consumers. для публичных consumers.
Problem Details фильтруются общей HTTP boundary; StructuredLogger добавляет Problem Details фильтруются общей HTTP boundary; native Pino добавляет request
request context и ограничивает поля событий. Jest unit tests, изолированные context, StructuredLogger ограничивает поля событий. Jest unit tests, изолированные
SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения. SQLite/HTTP suites и OpenAPI parity доказывают разные части поведения.
Локальные источники выбираются через [Источники](../AGENTS.md#источники). Локальные источники выбираются через [Источники](../AGENTS.md#источники).
+29 -12
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,28 @@ 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, раздел «Релизы». обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
+4
View File
@@ -43,6 +43,10 @@ CA mechanism. Точные параметры и ограничения прин
[validated environment](../../src/config/env.validation.ts) и [validated environment](../../src/config/env.validation.ts) и
[TbankClient](../../src/integrations/tbank/tbank.client.ts). [TbankClient](../../src/integrations/tbank/tbank.client.ts).
Все RPC, включая metadata, допускают один повтор временной upstream-ошибки.
Переполнение локальной очереди не повторяется. Свечи загружаются по требуемому
диапазону; prefetch портфеля и автоматический переход к `all` не поддерживаются.
Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют Эта queue/retry policy относится к T-Bank. MOEX/NCR HTTP clients используют
собственный bounded timeout и безопасный unavailable outcome; не приписывай им собственный bounded timeout и безопасный unavailable outcome; не приписывай им
gRPC retry policy. При изменении upstream сначала прочитай конкретный client. gRPC retry policy. При изменении upstream сначала прочитай конкретный client.
+5 -3
View File
@@ -7,10 +7,12 @@
- `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC; - `info` — значимый lifecycle/outcome; `debug` — подробности успешного RPC;
`warn` — восстановимая проблема/retry; `error` — окончательный сбой. `warn` — восстановимая проблема/retry; `error` — окончательный сбой.
Не добавляй log на каждый вход/выход функции или дубликат HTTP completion. Не добавляй log на каждый вход/выход функции или дубликат HTTP completion.
- RequestContextService и request middleware владеют correlation: серверный - Native `nestjs-pino` context и `pino-http` completion владеют correlation:
request ID возвращается как `X-Request-ID`, входящий client ID хранится отдельно. server request ID возвращается как `X-Request-ID`, client ID хранится отдельно.
Настройки принадлежат `common/logging/logging.module.ts`; второй AsyncLocalStorage
или HTTP completion middleware не нужен.
Передавай существующий context, не создавай собственный UUID в каждом service. Передавай существующий context, не создавай собственный UUID в каждом service.
- Wrapper ограничивает поля, Pino redacts request secrets. Не логируй passwords, - StructuredLogger ограничивает поля, Pino redacts request secrets. Не логируй passwords,
tokens, cookie, Authorization, credentials, connection generation, raw body tokens, cookie, Authorization, credentials, connection generation, raw body
или upstream error. Secret в `message/stack` не становится безопасным из-за или upstream error. Secret в `message/stack` не становится безопасным из-за
allowlist; правила [Security](security-auth.md) обязательны для sensitive data. allowlist; правила [Security](security-auth.md) обязательны для sensitive data.
+3 -1
View File
@@ -17,7 +17,9 @@ Source code конфигурации cookie —
проверки browser/HTTP поведения, не только TypeScript. проверки browser/HTTP поведения, не только TypeScript.
Refresh rotation атомарна: один победитель, проигравший/superseded token получает Refresh rotation атомарна: один победитель, проигравший/superseded token получает
401, но сам по себе не отзывает session победителя. Не вводи replay history/grace 401, но не меняет cookie и сам по себе не отзывает session победителя.
Cookie очищает logout; межвкладочная синхронизация не требуется, допустим re-login.
Не вводи Web Locks, replay history/grace
или иной security contract без текущей задачи. Logout идемпотентен, отзывает или иной security contract без текущей задачи. Logout идемпотентен, отзывает
сессию и очищает refresh cookie на HTTP-границе. сессию и очищает refresh cookie на HTTP-границе.
+3 -1
View File
@@ -41,9 +41,11 @@ Unit specs colocated в `src/**/*.spec.ts`; HTTP/integration — `test/`.
Полный `npm run verify` включает lint, оба typecheck, Jest и build. Полный `npm run verify` включает lint, оба typecheck, Jest и build.
`npm run test:openapi-contract` — отдельный required check при wire contract change. `npm run test:openapi-contract` — отдельный required check при wire contract change.
`npm run test:e2e` запускает все HTTP suites через изолированный SQLite runner; `npm run test:e2e` запускает функциональные HTTP suites через изолированный SQLite runner;
`npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную `npm run test:sqlite` — только auth/cards. Каждая suite получает новую временную
базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов. базу со штатными migrations. Прямой Jest без runner отклоняется до DB-запросов.
Большой latency/load benchmark запускается отдельно через `npm run test:performance`:
его абсолютный wall-clock budget не является merge gate на shared runner.
`prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error` `prisma.config.ts` исключает влияние унаследованного `RUST_LOG=warn/error`
на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска — на fresh migration; точная Prisma version принадлежит manifest/lockfile. Подробности и регрессия запуска —
[Backend README](../../README.md#реальная-sqlite-и-сохранность-данных). [Backend README](../../README.md#реальная-sqlite-и-сохранность-данных).
+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 (если есть)
+9 -3
View File
@@ -4,7 +4,7 @@ on:
push: push:
branches: [develop, main] branches: [develop, main]
pull_request: pull_request:
types: [opened, synchronize, reopened, edited] types: [opened, synchronize, reopened]
schedule: schedule:
- cron: '47 5 * * *' - cron: '47 5 * * *'
@@ -34,7 +34,11 @@ 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
@@ -60,7 +64,9 @@ jobs:
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: npm audit --include=dev --audit-level=low run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security] needs: [commits, security]
+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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
+93
View File
@@ -1,5 +1,98 @@
# Changelog # Changelog
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES
* remove investment stateVersion, queued sync state,
and tbank-sync status progress/resources. Consumers must observe
resources through sync=observe with the updated frontend contract.
### Bug Fixes
* **deps:** resolve npm audit advisories ([15a3d5f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/15a3d5f331a0368032a747b4acd60db824127d99))
* **deps:** restore backend security gate ([30e96e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/30e96e51e2622e3168f9137769d0343777243dcf))
* harden auth and investment synchronization ([4796d9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4796d9d3f0160d96004a982f6c2526f199de4b9f))
* **investments:** align roles and date contracts ([516e432](https://git.personal.ksv741.ru/WealthLedger/backend/commit/516e43203eb3fee8e6a0c7018be2fee11f1b900c))
* resolve build imports before linting ([b244330](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b24433092bd7deeb12589ee4c1fbc7f9e6b2a8b0))
* resolve build imports before linting ([7d2262f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d2262fb439298e73d2651aabae7c6dc6e2107d5))
### Performance
* group catalog source reads by identity ([6cd9c2d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6cd9c2dd28f7f65eebb4dbfc0b459c52b0e8fe35))
* group catalog source reads by identity ([e76eb54](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e76eb545d7d2bbd763e696c947da9e0cd34e1ecd))
* **income:** compute Moscow dates once ([a7619d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a7619d5c8fbb1a741727a0864dbb627ce2a190be))
### Refactoring
* centralize provider projections ([7fe88a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7fe88a12c7cd3c5eb16a1912c5bd55d171a64c95))
* clarify nullable card metadata ([190eb90](https://git.personal.ksv741.ru/WealthLedger/backend/commit/190eb90ffe0b55a0e71047e9778aa77e167841a1))
* enforce enum machine codes ([33592c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/33592c1d5ff582ce57b596ae4e2324b1cea2b0f4))
* enforce enum machine codes ([2d14081](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2d14081d27935dc85da466e5d9713d2f703ca673))
* isolate operation ranges and intents ([a13cf84](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a13cf84eadb0b36bd0c364a1d6f10831a8fdc44c))
* replace ignored numeric literals with constants ([b78c215](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b78c215ff13e908621e6970cfdcdd4f483e45d2a))
* replace ignored numeric literals with constants ([9cc9fbd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9cc9fbdcff91f1a58c78ab83eea5fc0bc80106c7))
* share authentication result type ([fb2e851](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fb2e851e7e3f58c915ba1086212aa7957c3f9ed3))
* simplify investment data flow ([1be6db6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1be6db65d70f6a96629988289500098b2728b2eb))
* simplify runtime coordination [#7](https://git.personal.ksv741.ru/WealthLedger/backend/issues/7) ([d698ed9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d698ed9425a5da49846ffe4e91a396e3f2d91f72))
### CI
* reduce duplicate actions and serialize checks ([53b2794](https://git.personal.ksv741.ru/WealthLedger/backend/commit/53b27949631cadda10f988ff1920ac813d175ccc))
### Tests
* separate performance budget from e2e gate ([01e2de6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/01e2de6a041e75da739b8288d055b1dce82d38a2))
### Styles
* align multiline ternary operators ([183b7f1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/183b7f1efdafb77f2eb5afb82160f79feb642887))
* align multiline ternary operators ([0303e60](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0303e60b64c507b03fba91f9987f024cf28c95cc))
### Maintenance
* guard backend responsibility boundaries ([14a204d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14a204de0d32ad78193c614bef28513d1f97c593))
* strengthen backend eslint profile ([a970eaa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a970eaaf7a5549d1c51844ee6fc06c1aaf5afa56))
* strengthen backend eslint profile ([9b3558f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b3558f96c88ff625deae83c07d2087c2ceabfe9))
## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26) ## [1.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v0.2.3...v1.0.0) (2026-09-26)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+16 -7
View File
@@ -63,9 +63,12 @@ npm run verify
Он запускает zero-warning lint, Он запускает zero-warning lint,
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
threshold нет. `npm run test:e2e` отдельно проверяет все HTTP suites на новых базах threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
и соответствие генерируемого OpenAPI настроенному приложению. Для полной backend-проверки на новых базах и соответствие генерируемого OpenAPI настроенному приложению.
выполните обе команды. Для полной backend-проверки выполните обе команды. Тяжёлый synthetic benchmark
с 300 000 операций и 1 000 000 свечей запускается отдельно командой
`npm run test:performance`: его wall-clock budget зависит от выделенного runner CPU
и не является функциональным merge gate.
Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`, Nest 12 поставляется как ESM. Скрипты Jest включают `--experimental-vm-modules`,
включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6 включая watch/coverage/debug; SQLite runner включает тот же флаг. TypeScript 6
@@ -81,12 +84,16 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
Новый запуск отменяет устаревший для того же 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`, генерацию Prisma client,
`npm run verify` и `npm run test:e2e`. Используется только checkout `npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми Job `security` сканирует всю Git-историю Gitleaks 8.30.1 с полностью скрытыми
значениями и выполняет `npm audit --include=dev --audit-level=low`. значениями, блокирует любой runtime advisory через
`npm audit --omit=dev --audit-level=low` и high/critical findings полного дерева
через `npm audit --include=dev --audit-level=high`. Moderate advisory
`sprintf-js` остаётся только в Jest coverage tooling; исправленной версии пока
нет, а предложенный npm downgrade `ts-jest` несовместим с текущим test stack.
`.gitleaksignore` перечисляет только проверенные исторические `.gitleaksignore` перечисляет только проверенные исторические
совпадения: детерминированные fixtures, placeholder и badge из Nest starter. совпадения: детерминированные fixtures, placeholder и badge из Nest starter.
Новые коммиты этими исключениями не покрываются. Новые коммиты этими исключениями не покрываются.
@@ -288,9 +295,11 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`. релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай. Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного `develop` содержит последний релиз. Создай `release/<name>` от проверенного
+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 -33
View File
@@ -4,11 +4,6 @@
"count": 1 "count": 1
} }
}, },
"src/common/logging/request-logging.middleware.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/integrations/tbank/instrument-metadata.repository.ts": { "src/integrations/tbank/instrument-metadata.repository.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -37,11 +32,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/income/income-analytics.service.ts": { "src/modules/investments/income/income-analytics.service.ts": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -54,7 +44,7 @@
}, },
"src/modules/investments/instruments/application/candle.service.ts": { "src/modules/investments/instruments/application/candle.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 4 "count": 3
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 4 "count": 4
@@ -72,9 +62,6 @@
} }
}, },
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 2
},
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
} }
@@ -107,11 +94,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/investment-resource.mapper.ts": {
"sonarjs/no-nested-conditional": {
"count": 3
}
},
"src/modules/investments/investment-sync-plan.ts": { "src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
@@ -154,14 +136,6 @@
"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
@@ -169,17 +143,12 @@
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": { "src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 3 "count": 1
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 7 "count": 7
} }
}, },
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
+16 -12
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "1.0.0", "version": "2.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "1.0.0", "version": "2.1.0",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.5", "@grpc/grpc-js": "1.14.5",
@@ -6125,9 +6125,9 @@
} }
}, },
"node_modules/babel-plugin-istanbul": { "node_modules/babel-plugin-istanbul": {
"version": "8.0.0", "version": "8.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.0.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-8.0.2.tgz",
"integrity": "sha512-18wCskrN3DgbuBmp1gr7LBGT8xdz5xhQQqFvFhVxbkl8VBCrMKQ2YtqBWtUal1Zrc1HTuX0011+Brjw78TCFkg==", "integrity": "sha512-bXWZeWQvvCZcD2uHLWVbitr4s1+YqILBbWB/szXKtHXt6crLlD6mqxHZ0qWy2X/Xx5RtQvqqW80JHAXMlp+y1Q==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"workspaces": [ "workspaces": [
@@ -9517,13 +9517,13 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/handlebars": { "node_modules/handlebars": {
"version": "4.7.9", "version": "4.7.10",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.10.tgz",
"integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", "integrity": "sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"minimist": "^1.2.5", "minimist": "^1.2.8",
"neo-async": "^2.6.2", "neo-async": "^2.6.2",
"source-map": "^0.6.1", "source-map": "^0.6.1",
"wordwrap": "^1.0.0" "wordwrap": "^1.0.0"
@@ -12692,9 +12692,9 @@
"license": "MIT" "license": "MIT"
}, },
"node_modules/proxy-addr": { "node_modules/proxy-addr": {
"version": "2.0.7", "version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"forwarded": "0.2.0", "forwarded": "0.2.0",
@@ -12702,6 +12702,10 @@
}, },
"engines": { "engines": {
"node": ">= 0.10" "node": ">= 0.10"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/proxy-agent": { "node_modules/proxy-agent": {
+3 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "1.0.0", "version": "2.1.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -26,6 +26,7 @@
"test:cov": "npm test -- --coverage", "test:cov": "npm test -- --coverage",
"test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", "test:debug": "node --experimental-vm-modules --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "npm run build && node test/sqlite.cjs --all", "test:e2e": "npm run build && node test/sqlite.cjs --all",
"test:performance": "npm run build && node test/sqlite.cjs tbank-read-baseline.e2e-spec.ts",
"test:sqlite": "node test/sqlite.cjs", "test:sqlite": "node test/sqlite.cjs",
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
@@ -104,6 +105,7 @@
"typescript-eslint": "8.70.1" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8",
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.4.0" "multer": "2.4.0"
}, },
@@ -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
-1
View File
@@ -5,7 +5,6 @@ const { version } = require('../package.json');
// eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input. // eslint-disable-next-line sonarjs/no-os-command-from-path -- Developer-only command uses the operator PATH and fixed executable/arguments; no request input.
const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim(); const git = (...args) => execFileSync('git', args, { encoding: 'utf8' }).trim();
assert.equal(git('status', '--porcelain'), '', 'Commit all changes before preparing a release');
const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname') const tags = git('tag', '--merged', 'HEAD', '--list', 'v[0-9]*', '--sort=-version:refname')
.split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag)); .split('\n').filter((tag) => /^v\d+\.\d+\.\d+$/.test(tag));
assert.equal(version, tags[0]?.slice(1) ?? '0.0.0', assert.equal(version, tags[0]?.slice(1) ?? '0.0.0',
+2 -36
View File
@@ -1,10 +1,8 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config'; import { ConfigModule } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { LoggingModule } from './common/logging/logging.module'; import { LoggingModule } from './common/logging/logging.module';
import { Environment, validateEnv } from './config/env.validation'; import { validateEnv } from './config/env.validation';
import { AuthModule } from './modules/auth/auth.module'; import { AuthModule } from './modules/auth/auth.module';
import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module'; import { BankCardsModule } from './modules/banking/bank-cards/bank-cards.module';
import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module'; import { BrokerageAccountsModule } from './modules/investments/brokerage-accounts/brokerage-accounts.module';
@@ -23,38 +21,6 @@ import { PrismaModule } from './prisma/prisma.module';
isGlobal: true, isGlobal: true,
validate: validateEnv, validate: validateEnv,
}), }),
LoggerModule.forRootAsync({
inject: [ConfigService],
useFactory: (config: ConfigService<Environment, true>) => ({
pinoHttp: {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
autoLogging: false,
genReqId: (request: unknown, response) => {
const existingId = (request as { id?: unknown }).id;
const requestId =
typeof existingId === 'string' ? existingId : randomUUID();
response.setHeader('X-Request-ID', requestId);
return requestId;
},
redact: [
'req.headers.authorization',
'req.headers.cookie',
'req.body',
],
serializers: {
req: (request: unknown) => {
const value = request as { id?: unknown; method?: unknown };
return {
id: typeof value.id === 'string' ? value.id : undefined,
method:
typeof value.method === 'string' ? value.method : undefined,
};
},
},
},
}),
}),
LoggingModule, LoggingModule,
PrismaModule, PrismaModule,
AuthModule, AuthModule,
+59 -9
View File
@@ -1,16 +1,66 @@
import { Global, Module } from '@nestjs/common'; import type { Request, Response } from 'express';
import type { Options } from 'pino-http';
import { RequestContextService } from './request-context.service'; import { Global, Module } from '@nestjs/common';
import { RequestLoggingMiddleware } from './request-logging.middleware'; import { ConfigService } from '@nestjs/config';
import { LoggerModule } from 'nestjs-pino';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { Environment } from '../../config/env.validation';
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function completion(request: Request, response: Response, value: { responseTime: number }) {
const route: unknown = request.route;
const path = typeof route === 'object' && route !== null && 'path' in route &&
typeof route.path === 'string'
? `${request.baseUrl}${route.path}`
: '<unmatched>';
return {
method: request.method,
route: path,
status: response.statusCode,
durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
};
}
export function loggingOptions(config: ConfigService<Environment, true>): Options<Request, Response> {
return {
level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message',
genReqId: (_request, response) => {
const id = randomUUID();
response.setHeader('X-Request-ID', id);
return id;
},
customProps: (request) => {
const incoming = request.header('x-request-id');
return { requestId: request.id, ...(incoming && uuid.test(incoming) ? { clientRequestId: incoming } : {}) };
},
customLogLevel: (_request, response) => {
if (response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR) return 'error';
if ([httpStatus.HTTP_STATUS_FORBIDDEN, httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS, httpStatus.HTTP_STATUS_UNAUTHORIZED].includes(response.statusCode)) return 'warn';
return 'info';
},
customSuccessMessage: () => 'http.request.completed',
customErrorMessage: () => 'http.request.completed',
customSuccessObject: completion,
customErrorObject: (request, response, _error, value: { responseTime: number }) => completion(request, response, value),
redact: ['req.headers.authorization', 'req.headers.cookie', 'req.body'],
serializers: { req: () => undefined },
};
}
@Global() @Global()
@Module({ @Module({
providers: [ imports: [LoggerModule.forRootAsync<Request, Response>({
RequestContextService, inject: [ConfigService],
RequestLoggingMiddleware, useFactory: (config: ConfigService<Environment, true>) => ({ pinoHttp: loggingOptions(config), assignResponse: true }),
StructuredLogger, })],
], providers: [StructuredLogger],
exports: [RequestContextService, RequestLoggingMiddleware, StructuredLogger], exports: [StructuredLogger],
}) })
export class LoggingModule {} export class LoggingModule {}
@@ -1,27 +0,0 @@
import { Injectable } from '@nestjs/common';
import { AsyncLocalStorage } from 'node:async_hooks';
export type RequestLogContext = {
brokerageAccountId?: string;
clientRequestId?: string;
requestId: string;
userId?: string;
};
@Injectable()
export class RequestContextService {
private readonly storage = new AsyncLocalStorage<RequestLogContext>();
run<T>(context: RequestLogContext, callback: () => T): T {
return this.storage.run(context, callback);
}
get(): RequestLogContext | undefined {
return this.storage.getStore();
}
set(context: Partial<RequestLogContext>): void {
const current = this.storage.getStore();
if (current) Object.assign(current, context);
}
}
@@ -1,54 +0,0 @@
import { EventEmitter } from 'node:events';
import { RequestLoggingMiddleware } from './request-logging.middleware';
describe('RequestLoggingMiddleware', () => {
it('returns a server ID and emits one safe completion event', () => {
const requestContext = {
run: jest.fn((_: unknown, next: () => void) => next()),
};
const logger = { event: jest.fn() };
const middleware = new RequestLoggingMiddleware(
requestContext as never,
logger as never,
);
const response = Object.assign(new EventEmitter(), {
statusCode: 200,
locals: {},
setHeader: jest.fn(),
});
const next = jest.fn();
const request: {
baseUrl: string;
header: jest.Mock<string>;
id?: string;
method: string;
route: { path: string };
} = {
method: 'GET',
baseUrl: '/api/v1',
route: { path: '/accounts/:id' },
header: jest.fn(() => 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'),
};
middleware.use(request as never, response as never, next);
response.emit('finish');
expect(response.setHeader).toHaveBeenCalledWith(
'X-Request-ID',
expect.stringMatching(/^[0-9a-f-]{36}$/i),
);
expect(request.id).toMatch(/^[0-9a-f-]{36}$/i);
expect(requestContext.run).toHaveBeenCalledWith(
expect.objectContaining({
clientRequestId: 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43',
}),
next,
);
expect(logger.event).toHaveBeenCalledWith(
'info',
'http.request.completed',
expect.objectContaining({ route: '/api/v1/accounts/:id', status: 200 }),
);
});
});
@@ -1,67 +0,0 @@
import type { NextFunction, Request, Response } from 'express';
import { Injectable, NestMiddleware } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { constants as httpStatus } from 'node:http2';
import { RequestContextService } from './request-context.service';
import { StructuredLogger } from './structured-logger.service';
const uuid =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
@Injectable()
export class RequestLoggingMiddleware implements NestMiddleware {
constructor(
private readonly requestContext: RequestContextService,
private readonly logger: StructuredLogger,
) {}
use(request: Request, response: Response, next: NextFunction): void {
const incoming = request.header('x-request-id');
const clientRequestId =
incoming && uuid.test(incoming) ? incoming : undefined;
const existingRequestId = (request as Request & { id?: unknown }).id;
const requestId =
typeof existingRequestId === 'string' ? existingRequestId : randomUUID();
(request as unknown as { id: string }).id = requestId;
const startedAt = performance.now();
response.setHeader('X-Request-ID', requestId);
response.on('finish', () => {
const routeRequest: unknown = request;
const routePath = (routeRequest as { route?: { path?: unknown } }).route
?.path;
const route =
typeof routePath === 'string'
? `${request.baseUrl}${routePath}`
: '<unmatched>';
const context = {
method: request.method,
route,
status: response.statusCode,
durationMs: Math.round(performance.now() - startedAt),
...(response.locals.publicErrorCode
? { code: response.locals.publicErrorCode as string }
: {}),
};
const level =
response.statusCode >= httpStatus.HTTP_STATUS_INTERNAL_SERVER_ERROR
? 'error'
: response.statusCode === httpStatus.HTTP_STATUS_TOO_MANY_REQUESTS ||
response.statusCode === httpStatus.HTTP_STATUS_UNAUTHORIZED ||
response.statusCode === httpStatus.HTTP_STATUS_FORBIDDEN
? 'warn'
: 'info';
this.logger.event(level, 'http.request.completed', context);
});
this.requestContext.run(
{
requestId,
...(clientRequestId ? { clientRequestId } : {}),
},
next,
);
}
}
@@ -1,7 +1,6 @@
import { StructuredLogger } from './structured-logger.service'; import { StructuredLogger } from './structured-logger.service';
describe('StructuredLogger', () => { describe('StructuredLogger', () => {
const context = { get: jest.fn() };
const pino = { const pino = {
debug: jest.fn(), debug: jest.fn(),
info: jest.fn(), info: jest.fn(),
@@ -12,10 +11,10 @@ describe('StructuredLogger', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('passes only allowlisted fields to Pino', () => { it('passes only allowlisted fields to Pino', () => {
context.get.mockReturnValue({ requestId: 'request', token: 'secret' }); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.info('event', { logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -23,7 +22,7 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
requestId: 'request', asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -32,8 +31,7 @@ describe('StructuredLogger', () => {
}); });
it('delegates event levels to Pino', () => { it('delegates event levels to Pino', () => {
context.get.mockReturnValue(undefined); const logger = new StructuredLogger(pino as never);
const logger = new StructuredLogger(context as never, pino as never);
logger.event('debug', 'debug event'); logger.event('debug', 'debug event');
logger.event('warn', 'warn event'); logger.event('warn', 'warn event');
@@ -1,12 +1,11 @@
import { Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Injectable, OnApplicationShutdown } from '@nestjs/common';
import { PinoLogger } from 'nestjs-pino'; import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from './request-context.service';
type LogLevel = 'debug' | 'error' | 'info' | 'warn'; type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'asOf',
'attempts', 'attempts',
'brokerageAccountId', 'brokerageAccountId',
'clientRequestId', 'clientRequestId',
@@ -27,10 +26,7 @@ const allowedFields = new Set([
@Injectable() @Injectable()
export class StructuredLogger implements OnApplicationShutdown { export class StructuredLogger implements OnApplicationShutdown {
constructor( constructor(private readonly pino: PinoLogger) {}
private readonly requestContext: RequestContextService,
private readonly pino: PinoLogger,
) {}
info(message: string, context?: LogContext): void { info(message: string, context?: LogContext): void {
this.write('info', message, context); this.write('info', message, context);
@@ -51,7 +47,7 @@ export class StructuredLogger implements OnApplicationShutdown {
): void { ): void {
// eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union. // eslint-disable-next-line security/detect-object-injection -- The log level is restricted to the internal LogLevel union.
this.pino[level]( this.pino[level](
this.safeContext({ ...this.requestContext.get(), ...context }), this.safeContext(context),
message, message,
); );
} }
@@ -12,7 +12,6 @@ import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceTtlMs } from './tbank-source.policy';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -320,11 +319,7 @@ function selectMetadata(
return { return {
data: usefulUid ? uid.data : (figi.data ?? uid.data), data: usefulUid ? uid.data : (figi.data ?? uid.data),
complete, complete,
fresh: fresh: complete,
complete &&
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
fetchedAt !== null &&
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
fetchedAt, fetchedAt,
dataRevision: used dataRevision: used
.map( .map(
@@ -121,6 +121,30 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
async onlineToken(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return this.tokens.decrypt(connection.encryptedToken);
}
async current(userId: string): Promise<{ revision: string; token: string }> {
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 });
}
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
}
async publishMembership( async publishMembership(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
accounts: TbankAccount[], accounts: TbankAccount[],
@@ -192,6 +216,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(
+150 -48
View File
@@ -176,46 +176,30 @@ describe('TbankClient metadata retries', () => {
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
status.RESOURCE_EXHAUSTED, status.RESOURCE_EXHAUSTED,
])( ])(
'recovers metadata after transient gRPC status %s with 2s/4s pauses', 'recovers metadata after one retry for transient gRPC status %s',
async (code) => { async (code) => {
requestRpc requestRpc
.mockRejectedValueOnce({ code })
.mockRejectedValueOnce({ code }) .mockRejectedValueOnce({ code })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid');
await jest.advanceTimersByTimeAsync(1999); await jest.advanceTimersByTimeAsync(999);
expect(requestRpc).toHaveBeenCalledTimes(1); expect(requestRpc).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1); await jest.advanceTimersByTimeAsync(1);
expect(requestRpc).toHaveBeenCalledTimes(2); expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(3999);
expect(requestRpc).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument); await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}, },
); );
it('rechecks a full queue before admitting a metadata retry', async () => { it('rejects metadata immediately when the queue is full', async () => {
queue.size = 50; queue.size = 50;
requestRpc.mockResolvedValue({ instrument }); requestRpc.mockResolvedValue({ instrument });
const result = client.getInstrumentByFigi('token', 'figi'); await expect(client.getInstrumentByFigi('token', 'figi')).rejects.toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(1999);
expect(queue.run).not.toHaveBeenCalled(); expect(queue.run).not.toHaveBeenCalled();
queue.size = 49; expect(jest.getTimerCount()).toBe(0);
await jest.advanceTimersByTimeAsync(1);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(1);
expect(logger.event).toHaveBeenCalledWith(
'warn',
'tbank.rpc.retry',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 1 }),
);
expect(logger.event.mock.calls[0][2]).not.toHaveProperty('grpcCode');
}); });
it('does not bypass queue admission after an upstream failure', async () => { it('does not bypass queue admission after an upstream failure', async () => {
@@ -223,42 +207,22 @@ describe('TbankClient metadata retries', () => {
.mockRejectedValueOnce({ code: status.UNAVAILABLE }) .mockRejectedValueOnce({ code: status.UNAVAILABLE })
.mockResolvedValueOnce({ instrument }); .mockResolvedValueOnce({ instrument });
const result = client.getInstrumentByUid('token', 'uid'); const result = client.getInstrumentByUid('token', 'uid').catch((error: unknown) => error);
queue.size = 50; queue.size = 50;
await jest.advanceTimersByTimeAsync(2000); await jest.advanceTimersByTimeAsync(1000);
expect(queue.run).toHaveBeenCalledTimes(1); expect(queue.run).toHaveBeenCalledTimes(1);
queue.size = 0; expect(await result).toBeInstanceOf(ServiceUnavailableException);
await jest.advanceTimersByTimeAsync(4000);
await expect(result).resolves.toEqual(instrument);
expect(queue.run).toHaveBeenCalledTimes(2);
});
it('stops retrying a saturated queue after three admission attempts', async () => {
queue.size = 50;
await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000),
]);
expect(queue.run).not.toHaveBeenCalled();
expect(logger.event).toHaveBeenCalledTimes(3);
expect(logger.event).toHaveBeenLastCalledWith(
'error',
'tbank.rpc.failed',
expect.objectContaining({ code: 'TBANK_UNAVAILABLE', attempts: 3 }),
);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
it('stops retrying an unavailable upstream after three RPCs', async () => { it('stops retrying an unavailable upstream after two RPCs', async () => {
requestRpc.mockRejectedValue({ code: status.UNAVAILABLE }); requestRpc.mockRejectedValue({ code: status.UNAVAILABLE });
await Promise.all([ await Promise.all([
expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException), expect(client.getInstrumentByUid('token', 'uid')).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(6000), jest.advanceTimersByTimeAsync(1000),
]); ]);
expect(queue.run).toHaveBeenCalledTimes(3); expect(queue.run).toHaveBeenCalledTimes(2);
expect(jest.getTimerCount()).toBe(0); expect(jest.getTimerCount()).toBe(0);
}); });
@@ -318,3 +282,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',
);
});
});
+39 -27
View File
@@ -34,6 +34,7 @@ import {
InstrumentIdType, InstrumentIdType,
TbankAccountStatus, TbankAccountStatus,
TbankCandleInterval, TbankCandleInterval,
TbankInstrumentStatus,
TbankInstrumentType, TbankInstrumentType,
TbankOperationState, TbankOperationState,
} from './tbank-protocol'; } from './tbank-protocol';
@@ -46,10 +47,8 @@ const NO_RPC_ATTEMPTS = 0;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
const RATE_WINDOW_MS = 1_000; const RATE_WINDOW_MS = 1_000;
const METADATA_MAX_ATTEMPTS = 3;
const RPC_MAX_ATTEMPTS = 2; const RPC_MAX_ATTEMPTS = 2;
const MAX_QUEUE_SIZE = 50; const MAX_QUEUE_SIZE = 50;
const METADATA_RETRY_DELAY_MS = 2_000;
const RPC_RETRY_DELAY_MS = 1_000; const RPC_RETRY_DELAY_MS = 1_000;
const requestDeadlineMs = 15_000; const requestDeadlineMs = 15_000;
@@ -327,6 +326,35 @@ export class TbankClient {
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
async getAllShares(token: string): Promise<TbankShare[]> {
const response = await this.call<{ instruments?: TbankShare[] }>(
token,
this.instruments,
'shares',
{ instrumentStatus: TbankInstrumentStatus.All },
);
const { instruments } = response;
if (
!Array.isArray(instruments) ||
!instruments.length ||
instruments.some((item) => !item?.uid)
) {
this.logger.event('error', 'tbank.shares.catalog.unusable', {
code: TbankErrorCode.Unavailable,
operation: 'shares',
});
throw new ServiceUnavailableException({
code: TbankErrorCode.Unavailable,
});
}
this.logger.event('info', 'tbank.shares.catalog.fetched', {
asOf: new Date().toISOString(),
count: instruments.length,
operation: 'shares',
});
return instruments;
}
findBonds(token: string, query: string): Promise<TbankBond[]> { findBonds(token: string, query: string): Promise<TbankBond[]> {
return this.call<{ instruments?: TbankBond[] }>( return this.call<{ instruments?: TbankBond[] }>(
token, token,
@@ -419,12 +447,6 @@ export class TbankClient {
).then((response) => response.lastPrices ?? []); ).then((response) => response.lastPrices ?? []);
} }
getLastPrice(token: string, uid: string): Promise<null | TbankLastPrice> {
return this.getLastPrices(token, [uid]).then(
(prices) => prices.find((item) => item.instrumentUid === uid) ?? null,
);
}
getTradingStatus(token: string, uid: string): Promise<null | string> { getTradingStatus(token: string, uid: string): Promise<null | string> {
return this.call<{ tradingStatus?: string }>( return this.call<{ tradingStatus?: string }>(
token, token,
@@ -486,8 +508,7 @@ export class TbankClient {
request: object, request: object,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
const maxAttempts = metadataCall ? METADATA_MAX_ATTEMPTS : RPC_MAX_ATTEMPTS; for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
for (let attempts = FIRST_ATTEMPT; attempts <= maxAttempts; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
try { try {
@@ -508,34 +529,25 @@ export class TbankClient {
const failureContext = queueFull const failureContext = queueFull
? { code: TbankErrorCode.Unavailable } ? { code: TbankErrorCode.Unavailable }
: { grpcCode }; : { grpcCode };
const retryable = queueFull const retryable = !queueFull && [
? metadataCall status.DEADLINE_EXCEEDED,
: [ status.RESOURCE_EXHAUSTED,
status.DEADLINE_EXCEEDED, status.UNAVAILABLE,
status.RESOURCE_EXHAUSTED, ].includes(grpcCode);
status.UNAVAILABLE, if (retryable && attempts < RPC_MAX_ATTEMPTS) {
].includes(grpcCode);
if (retryable && attempts < maxAttempts) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
await new Promise((resolve) => await new Promise((resolve) => setTimeout(resolve, RPC_RETRY_DELAY_MS));
setTimeout(
resolve,
metadataCall
? attempts * METADATA_RETRY_DELAY_MS
: RPC_RETRY_DELAY_MS,
),
);
continue; continue;
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...failureContext, ...failureContext,
attempts: queueFull && !metadataCall ? NO_RPC_ATTEMPTS : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
if ( if (
-3
View File
@@ -11,7 +11,6 @@ import { Logger } from 'nestjs-pino';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import { HttpExceptionFilter } from './common/filters/http-exception.filter'; import { HttpExceptionFilter } from './common/filters/http-exception.filter';
import { RequestLoggingMiddleware } from './common/logging/request-logging.middleware';
import { StructuredLogger } from './common/logging/structured-logger.service'; import { StructuredLogger } from './common/logging/structured-logger.service';
import { Environment } from './config/env.validation'; import { Environment } from './config/env.validation';
import { shouldExposeSwagger } from './config/swagger.config'; import { shouldExposeSwagger } from './config/swagger.config';
@@ -29,8 +28,6 @@ export function configureApp(app: INestApplication): void {
}); });
app.setGlobalPrefix(API_PREFIX); app.setGlobalPrefix(API_PREFIX);
app.use(cookieParser()); app.use(cookieParser());
const requestLogging = app.get(RequestLoggingMiddleware);
app.use(requestLogging.use.bind(requestLogging));
app.useGlobalPipes( app.useGlobalPipes(
new ValidationPipe({ new ValidationPipe({
forbidUnknownValues: true, forbidUnknownValues: true,
@@ -85,14 +85,9 @@ export class AuthController {
@Req() request: Request, @Req() request: Request,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
try { const token = this.refreshToken(request);
const token = this.refreshToken(request); if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); return this.respond(response, await this.auth.refresh(token));
return this.respond(response, await this.auth.refresh(token));
} catch (error) {
if (error instanceof UnauthorizedException) this.clearCookie(response);
throw error;
}
} }
@Post('logout') @Post('logout')
@@ -1,12 +1,12 @@
import { ExecutionContext, Injectable } from '@nestjs/common'; import { ExecutionContext, Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport'; import { AuthGuard } from '@nestjs/passport';
import { PinoLogger } from 'nestjs-pino';
import { RequestContextService } from '../../../common/logging/request-context.service';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') { export class JwtAuthGuard extends AuthGuard('jwt') {
constructor(private readonly requestContext: RequestContextService) { constructor(private readonly logger: PinoLogger) {
super(); super();
} }
@@ -16,7 +16,7 @@ export class JwtAuthGuard extends AuthGuard('jwt') {
.switchToHttp() .switchToHttp()
.getRequest<{ user?: AccessPayload }>(); .getRequest<{ user?: AccessPayload }>();
if (request.user?.sub) { if (request.user?.sub) {
this.requestContext.set({ userId: request.user.sub }); this.logger.assign({ userId: request.user.sub });
} }
return Boolean(allowed); return Boolean(allowed);
} }
@@ -49,7 +49,7 @@ export class BankCardsRepository {
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) || isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
(error instanceof Error && (error instanceof Error &&
/SQLITE_BUSY|database is locked/.test(error.message)); /SQLITE_BUSY|database is locked/.test(error.message));
if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) { if (busy && attempt < MAX_WRITE_RETRY_COUNT) {
continue; continue;
} }
if (busy) conflict(BankErrorCode.WriteConflict); if (busy) conflict(BankErrorCode.WriteConflict);
@@ -2,6 +2,7 @@ import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto, isArray: true }) @ApiProperty({ type: MoneyDto, isArray: true })
@@ -55,4 +56,12 @@ export class BrokerageAccountsResourceDto {
@ApiProperty({ type: InvestmentResourceMetaDto }) @ApiProperty({ type: InvestmentResourceMetaDto })
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
@ApiProperty({
type: HourlySyncStatusDto,
nullable: true,
description:
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
})
declare sync: HourlySyncStatusDto | null;
} }
@@ -1 +0,0 @@
export { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,11 +1,10 @@
import type { Request } from 'express'; import type { Request } from 'express';
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -19,10 +18,7 @@ export class BrokerageAccountsController {
@Get() @Get()
@ApiOkResponse({ type: BrokerageAccountsResourceDto }) @ApiOkResponse({ type: BrokerageAccountsResourceDto })
list( list(@Req() request: Request & { user: AccessPayload }) {
@Req() request: Request & { user: AccessPayload }, return this.accounts.list(request.user.sub);
@Query() query: InvestmentReadQueryDto,
) {
return this.accounts.list(request.user.sub, query.sync === 'observe');
} }
} }
@@ -3,11 +3,12 @@ import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageAccountsController } from './brokerage-accounts.controller'; import { BrokerageAccountsController } from './brokerage-accounts.controller';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@Module({ @Module({
imports: [AuthModule, TbankModule, PortfolioModule], imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
controllers: [BrokerageAccountsController], controllers: [BrokerageAccountsController],
providers: [BrokerageAccountsService], providers: [BrokerageAccountsService],
exports: [BrokerageAccountsService], exports: [BrokerageAccountsService],
@@ -19,16 +19,17 @@ const meta = investmentResourceMeta({
describe('saved brokerage account projection', () => { describe('saved brokerage account projection', () => {
const portfolio = { saved: jest.fn() }; const portfolio = { saved: jest.fn() };
const service = new BrokerageAccountsService(portfolio as never); const sync = { hourly: jest.fn().mockResolvedValue(null) };
const service = new BrokerageAccountsService(portfolio as never, sync as never);
it('distinguishes unknown membership from a confirmed empty list', async () => { it('distinguishes unknown membership from a confirmed empty list', async () => {
portfolio.saved.mockResolvedValueOnce({ portfolio.saved.mockResolvedValueOnce({
membership: { ...meta, availability: 'missing' }, membership: { ...meta, completeness: 'incomplete' },
accounts: [], accounts: [],
}); });
expect((await service.list('owner', true)).data).toBeNull(); expect((await service.list('owner')).data).toBeNull();
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
expect((await service.list('owner', true)).data).toEqual([]); expect((await service.list('owner')).data).toEqual([]);
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
@@ -58,13 +59,46 @@ describe('saved brokerage account projection', () => {
}, },
], ],
}); });
const response = await service.list('owner', true); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }], totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('partial'); expect(response.meta.availability).toBe('available');
expect(response.meta.completeness).toBe('complete');
});
it('observes the latest hourly walk without starting another one', async () => {
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
sync.hourly.mockResolvedValue({
startedAt: new Date('2026-09-25T12:00:00Z'),
finishedAt: new Date('2026-09-25T12:01:00Z'),
state: 'partial',
sections: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
const response = await service.list('owner');
expect(response.sync).toEqual({
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
state: 'partial',
problems: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
});
expect(sync.hourly).toHaveBeenCalledWith('owner');
}); });
}); });
@@ -3,16 +3,20 @@ import { createHash } from 'node:crypto';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { BrokerageAccountDto } from './brokerage-account.dto'; import { 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,25 @@ 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 }
: 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,
}; };
} }
} }
@@ -48,7 +48,7 @@ describe('IncomeAnalyticsService', () => {
expect.objectContaining({ role: 'previous' }), expect.objectContaining({ role: 'previous' }),
expect.objectContaining({ expect.objectContaining({
role: 'year', role: 'year',
availability: 'missing', availability: 'temporarily-unavailable',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY', unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}), }),
]); ]);
@@ -189,6 +189,49 @@ describe('IncomeAnalyticsService', () => {
).toEqual(['10.25', '-0.25']); ).toEqual(['10.25', '-0.25']);
}); });
it('assigns signed payments to Moscow days and retains empty buckets', async () => {
seed([
{
...entry('before', 'OPERATION_TYPE_COUPON', '0.1', 'RUB'),
occurredAt: '2026-01-14T20:59:59.999Z',
},
{
...entry('after', 'OPERATION_TYPE_COUPON', '0.2', 'RUB'),
occurredAt: '2026-01-14T21:00:00.000Z',
},
{
...entry('reversal', 'OPERATION_TYPE_DIVIDEND', '-0.1', 'RUB'),
occurredAt: '2026-01-15T20:59:59.999Z',
},
]);
const response = await service.analytics(
'user',
'account',
'2026-01-14',
'2026-01-16',
);
expect(response.data?.buckets).toEqual([
{
period: { from: '2026-01-14', to: '2026-01-14' },
totalsByCurrency: [expect.objectContaining({
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-15', to: '2026-01-15' },
totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' },
})],
},
{
period: { from: '2026-01-16', to: '2026-01-16' },
totalsByCurrency: [],
},
]);
});
it('aggregates only exact coupon and dividend payments by source currency', async () => { it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation( operations.collectRanges.mockImplementation(
( (
@@ -119,7 +119,10 @@ export class IncomeAnalyticsService {
...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,
@@ -232,13 +235,16 @@ function buckets(
granularity: IncomeBucketGranularity, granularity: IncomeBucketGranularity,
entries: BrokerageOperationDto[], entries: BrokerageOperationDto[],
) { ) {
const datedEntries = entries.map((entry) => ({
entry,
date: moscowDate(new Date(entry.occurredAt)),
}));
return rangeBuckets(range, granularity).map((period) => ({ return rangeBuckets(range, granularity).map((period) => ({
period, period,
totalsByCurrency: totals( totalsByCurrency: totals(
entries.filter((entry) => { datedEntries
const date = moscowDate(new Date(entry.occurredAt)); .filter(({ date }) => date >= period.from && date <= period.to)
return date >= period.from && date <= period.to; .map(({ entry }) => entry),
}),
), ),
})); }));
} }
@@ -1 +0,0 @@
export { IncomeAnalyticsService } from './income-analytics.service';
@@ -15,7 +15,6 @@ import {
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { planInvestmentScreen } from '../../investment-sync-plan';
import { import {
candleGaps, candleGaps,
type CandleRange, type CandleRange,
@@ -28,10 +27,9 @@ import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { CatalogEnrichmentService } from './catalog-enrichment.service'; import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { timestamp } from './instrument-market'; import { timestamp } from './instrument-market';
const FIRST_BATCH_OFFSET = 0;
const BACKGROUND_SYNC_PRIORITY = 2;
const REPLACED_WINDOW_COUNT = 1; const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1; const NEXT_WINDOW_OFFSET = 1;
const NO_CANDLE_GAPS = 0;
type CandleInput = { type CandleInput = {
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
@@ -55,7 +53,6 @@ type Producer = {
}; };
const months = { month: 1, halfYear: 6, year: 12 }; const months = { month: 1, halfYear: 6, year: 12 };
const tailFreshForMs = 60_000; const tailFreshForMs = 60_000;
const prefetchReadBatchSize = 100;
function scope(input: CandleInput) { function scope(input: CandleInput) {
return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`; return `candles:${input.uid}:${input.period}${input.range ? `:${input.range.from.toISOString()}/${input.range.to.toISOString()}` : ''}`;
} }
@@ -93,7 +90,6 @@ export class CandleService {
private readonly queue: Producer[] = []; private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve(); private reservations: Promise<unknown> = Promise.resolve();
private running = false; private running = false;
private readonly prefetches = new Map<string, Promise<void>>();
constructor( constructor(
private readonly repository: CandleRepository, private readonly repository: CandleRepository,
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
@@ -111,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();
@@ -119,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));
@@ -205,110 +203,6 @@ export class CandleService {
}; };
} }
prefetch(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const key = JSON.stringify([
userId,
revision,
positions
.map((position) => [position.instrumentUid, position.type])
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
.sort(),
]);
const existing = this.prefetches.get(key);
if (existing) return existing;
const work = this.prefetchPositions(userId, revision, positions).finally(
() => this.prefetches.delete(key),
);
this.prefetches.set(key, work);
return work;
}
private async prefetchPositions(
userId: string,
revision: string,
positions: { instrumentUid?: null | string; type: string }[],
) {
const now = new Date();
const unique = new Map(
positions
.filter(
(position) =>
position.instrumentUid && ['BOND', 'STOCK'].includes(position.type),
)
.map((position) => [position.instrumentUid ?? '', position]),
);
const details: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
[];
const coverage: Awaited<ReturnType<CandleRepository['coverage']>> = [];
for (const instrumentType of ['share', 'bond'] as const) {
const uids = [...unique]
.filter(
([, position]) =>
position.type === (instrumentType === 'share' ? 'STOCK' : 'BOND'),
)
.map(([uid]) => uid);
for (
let offset = FIRST_BATCH_OFFSET;
offset < uids.length;
offset += prefetchReadBatchSize
) {
const batch = uids.slice(offset, offset + prefetchReadBatchSize);
details.push(
...(await this.sources.readMany(userId, instrumentType, batch, true, [
'detail',
])),
);
coverage.push(...(await this.repository.coverage(batch)));
}
}
const byUid = new Map(details.map((detail) => [detail.input.uid, detail]));
for (const [uid, position] of unique) {
const instrumentType: CandleInput['instrumentType'] =
position.type === 'STOCK' ? 'share' : 'bond';
const detail = byUid.get(uid);
const history = timestamp(detail?.instrument?.first_1dayCandleDate);
const plan = planInvestmentScreen(
{ screen: 'account' },
{
membershipKnown: true,
accounts: [],
instruments: [
{
uid,
type: position.type as 'BOND' | 'STOCK',
historyFrom: history ? new Date(history) : null,
},
],
},
now,
);
if (
!detail?.instrument ||
plan.sources.some((source) => source.kind === 'candles')
) {
const input = { uid, instrumentType, period: 'all' as const };
const range =
detail?.instrument && history
? rangeFor(input, detail.instrument, now)
: null;
if (
!range ||
candleGaps(
range,
coverage.filter((row) => row.instrumentUid === uid),
now,
).length
) {
await this.request(userId, revision, input, now, false, BACKGROUND_SYNC_PRIORITY);
}
}
}
}
request( request(
userId: string, userId: string,
revision: string, revision: string,
@@ -499,17 +393,6 @@ export class CandleService {
this.reservations = finished.catch(() => undefined); this.reservations = finished.catch(() => undefined);
if (!(await finished)) { if (!(await finished)) {
this.queue.push(producer); this.queue.push(producer);
continue;
}
if (success && !producer.force && producer.input.period !== 'all') {
await this.request(
producer.attempt.userId,
producer.attempt.revision,
{ ...producer.input, period: 'all' },
producer.now,
false,
BACKGROUND_SYNC_PRIORITY,
);
} }
} }
} catch { } catch {
@@ -9,8 +9,9 @@ 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 {
TbankAccessService, TbankAccessService,
type TbankBond,
TbankClient, TbankClient,
tbankSourceNeedsRefresh, type TbankShare,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
@@ -31,6 +32,7 @@ import {
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0; const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const MISSING_DATA_REVISION = '0';
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
@@ -73,13 +75,14 @@ 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) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input, observe); const discovery = await this.read(userId, input, observe);
const sources = discovery.data const sources = discovery.data
? await this.enrichment.readMany( ? await this.enrichment.readMany(
@@ -118,13 +121,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 +146,25 @@ export class CatalogDiscoveryService {
uid: string, uid: string,
observe = false, observe = false,
) { ) {
if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await 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 +173,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;
@@ -182,7 +204,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,12 +217,37 @@ 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,
) { ) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId);
const found =
instrumentType === 'share'
? await this.tbank.findShares(token, query)
: await 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( const lookup = await this.read(
userId, userId,
{ instrumentType, query: ticker, exactTicker: true }, { instrumentType, query: ticker, exactTicker: true },
@@ -232,7 +279,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,6 +292,51 @@ export class CatalogDiscoveryService {
}; };
} }
private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId);
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const details = await Promise.all(
instruments.map((item) =>
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
);
const prices = instruments.length
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [];
const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => {
const detail = details.at(index);
return [{
...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}];
});
return {
data,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(input)}`,
dataRevision: `online:${fetchedAt.toISOString()}`,
availability: 'complete',
fetchedAt,
freshForMs: tbankSourceTtlMs.portfolio,
work: null,
}),
};
}
request( request(
userId: string, userId: string,
revision: string, revision: string,
@@ -255,10 +347,9 @@ export class CatalogDiscoveryService {
const reserved = this.reservations.then(async () => { const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input); const saved = await this.repository.read(input);
if ( if (
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force) !force &&
) { saved.dataRevision !== MISSING_DATA_REVISION
return null; ) return null;
}
const reservation = await this.repository.reserve( const reservation = await this.repository.reserve(
{ userId, revision }, { userId, revision },
input, input,
@@ -404,3 +495,37 @@ export class CatalogDiscoveryService {
} }
} }
} }
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,
};
}
@@ -58,7 +58,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 +154,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 +186,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 +208,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 });
@@ -234,6 +230,7 @@ export class CatalogEnrichmentService {
return reservation; return reservation;
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section( async section(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
@@ -241,6 +238,34 @@ export class CatalogEnrichmentService {
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false, observe = false,
) { ) {
if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId);
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];
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 +288,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 +297,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 +312,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 +322,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 +330,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 +346,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 +493,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 +580,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 +632,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 +650,40 @@ export type CatalogPayload = {
price?: null | TbankLastPrice; price?: null | TbankLastPrice;
tradingStatus?: null | string; tradingStatus?: null | string;
}; };
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,
};
}
function sourcePolicy(kind: CatalogSource['kind']) { function sourcePolicy(kind: CatalogSource['kind']) {
if ( if (
[ [
@@ -663,9 +696,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' &&
@@ -12,6 +12,7 @@ import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
type TbankCoupon, type TbankCoupon,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
@@ -70,8 +71,8 @@ export class BondIncomeService {
}; };
} }
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` }, meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
@@ -87,7 +88,7 @@ export class BondIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'bond', 'bond',
@@ -96,7 +97,7 @@ export class BondIncomeService {
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.coupons; const events = sources[1].payload?.coupons;
@@ -122,6 +123,10 @@ export class BondIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected return result.data || !projected
? result ? result
: { : {
@@ -132,7 +137,7 @@ export class BondIncomeService {
projected.observedAt, projected.observedAt,
true, true,
), ),
meta: { ...result.meta, availability: 'partial' }, meta: { ...result.meta, completeness: 'incomplete' },
}; };
} }
} }
@@ -119,13 +119,8 @@ export class BondController {
position( position(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.readPosition( return this.income.readPosition(request.user.sub, uid);
request.user.sub,
uid,
query.sync === 'observe',
);
} }
@Get(':uid/coupons') @Get(':uid/coupons')
@@ -53,12 +53,6 @@ export class CandleRepository {
}); });
} }
coverage(uids: string[]) {
return this.prisma.tbankCandleCoverage.findMany({
where: { provider, instrumentUid: { in: uids }, interval },
});
}
reserve( reserve(
owner: { revision: string; userId: string }, owner: { revision: string; userId: string },
scopeKey: string, scopeKey: string,
@@ -1,2 +0,0 @@
export { BondIncomeService } from './bonds/application/bond-income.service';
export { StockIncomeService } from './stocks/application/stock-income.service';
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
import { StockCatalogService } from './stocks/application/stock-catalog.service'; import { StockCatalogService } from './stocks/application/stock-catalog.service';
import { StockIncomeService } from './stocks/application/stock-income.service'; import { StockIncomeService } from './stocks/application/stock-income.service';
import { StockMarketService } from './stocks/application/stock-market.service'; import { StockMarketService } from './stocks/application/stock-market.service';
import { StockScreenerService } from './stocks/application/stock-screener.service';
import { StockController } from './stocks/presentation/stock.controller'; import { StockController } from './stocks/presentation/stock.controller';
@Module({ @Module({
@@ -19,12 +21,14 @@ import { StockController } from './stocks/presentation/stock.controller';
controllers: [StockController, BondController], controllers: [StockController, BondController],
exports: [StockIncomeService, BondIncomeService], exports: [StockIncomeService, BondIncomeService],
providers: [ providers: [
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
BondIncomeService, BondIncomeService,
BondMarketService, BondMarketService,
GetStockCompanyUseCase, GetStockCompanyUseCase,
StockIncomeService, StockIncomeService,
StockMarketService, StockMarketService,
StockCatalogService, StockCatalogService,
StockScreenerService,
BondCatalogService, BondCatalogService,
], ],
}) })
@@ -10,6 +10,7 @@ import { decimal } from '../../../../../integrations/tbank/decimal';
import { import {
TbankAccessService, TbankAccessService,
type TbankDividend, type TbankDividend,
tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
@@ -39,8 +40,8 @@ export class StockIncomeService {
private readonly forecasts: ForecastService, private readonly forecasts: ForecastService,
) {} ) {}
async readPosition(userId: string, uid: string, observe = false) { async readPosition(userId: string, uid: string) {
const saved = await this.portfolios.read(userId, 'all', observe); const saved = await this.portfolios.read(userId, 'all');
return { return {
data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null, data: saved.data ? instrumentPosition(saved.data, uid, 'STOCK') : null,
meta: { ...saved.meta, resourceKey: `stock/position/${uid}` }, meta: { ...saved.meta, resourceKey: `stock/position/${uid}` },
@@ -56,7 +57,7 @@ export class StockIncomeService {
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`; const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
const portfolio = await this.portfolios.read(userId, 'all', observe); const portfolio = await this.portfolios.read(userId, 'all');
const sources = await this.sources.readMany( const sources = await this.sources.readMany(
userId, userId,
'share', 'share',
@@ -65,7 +66,7 @@ export class StockIncomeService {
['detail', 'dividends'], ['detail', 'dividends'],
{ from, to }, { from, to },
); );
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) { if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
throw new NotFoundException(); throw new NotFoundException();
} }
const events = sources[1].payload?.dividends; const events = sources[1].payload?.dividends;
@@ -91,6 +92,10 @@ export class StockIncomeService {
candidate: candidate:
portfolio.data && sources[0].instrument ? projected : undefined, portfolio.data && sources[0].instrument ? projected : undefined,
}); });
if (result.data?.positionAt) {
result.data.positionIsStale = tbankSourceNeedsRefresh('portfolio', new Date(result.data.positionAt), new Date(Date.now()));
if (result.data.positionIsStale && result.meta.freshness !== 'unknown') result.meta.freshness = 'stale';
}
return result.data || !projected return result.data || !projected
? result ? result
: { : {
@@ -101,7 +106,7 @@ export class StockIncomeService {
projected.observedAt, projected.observedAt,
true, true,
), ),
meta: { ...result.meta, availability: 'partial' }, meta: { ...result.meta, completeness: 'incomplete' },
}; };
} }
} }
@@ -0,0 +1,106 @@
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 = { current: jest.fn(), local: jest.fn() };
const client = { getAllShares: jest.fn() };
const logger = { event: jest.fn() };
let service: StockScreenerService;
beforeEach(() => {
jest.clearAllMocks();
access.current.mockResolvedValue({ 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,140 @@
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 = {
current(userId: string): Promise<{ revision: string; token: string }>;
local(userId: string): Promise<{ revision: string }>;
};
@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 connection = await this.access.current(userId);
const instruments = await this.client.getAllShares(connection.token);
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: connection.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, type: 'integer' }) declare lot: null | number;
}
export class StockScreenerPageDto {
@ApiProperty() declare searchId: string;
@ApiProperty({ format: 'date-time' }) declare asOf: string;
@ApiProperty({ minimum: MIN_PAGE, type: 'integer' }) declare page: number;
@ApiProperty() declare hasNext: boolean;
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
}
export class StockSearchQueryDto extends InvestmentReadQueryDto { export class StockSearchQueryDto extends InvestmentReadQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@@ -1,9 +1,21 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator'; import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = ['missing', 'partial', 'complete'] as const; export const availabilityValues = [
'available',
'initial-loading',
'temporarily-unavailable',
'connection-unavailable',
] as const;
export const completenessValues = ['complete', 'incomplete'] as const;
export const freshnessValues = ['unknown', 'fresh', 'stale'] as const; export const freshnessValues = ['unknown', 'fresh', 'stale'] as const;
export const syncStateValues = ['idle', 'queued', 'running', 'failed'] as const; export const attemptStateValues = [
'none',
'running',
'succeeded',
'partial',
'failed',
] as const;
export class InvestmentReadQueryDto { export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] }) @ApiPropertyOptional({ enum: ['observe'] })
@@ -12,17 +24,31 @@ export class InvestmentReadQueryDto {
declare sync?: 'observe'; declare sync?: 'observe';
} }
export class InvestmentSyncDto { export class InvestmentAttemptProblemDto {
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare id: null | string; declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@ApiProperty({ enum: syncStateValues }) @ApiProperty({ type: String, nullable: true })
declare state: (typeof syncStateValues)[number]; declare accountId: null | string;
@ApiProperty({ type: String, nullable: true }) @ApiProperty({ type: String, nullable: true })
declare errorCode: null | string; declare errorCode: null | string;
} }
export class InvestmentAttemptDto {
@ApiProperty({ enum: attemptStateValues })
declare state: (typeof attemptStateValues)[number];
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare startedAt: null | string;
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
declare finishedAt: null | string;
@ApiProperty({ type: InvestmentAttemptProblemDto, isArray: true })
declare problems: InvestmentAttemptProblemDto[];
}
export class InvestmentRangeDto { export class InvestmentRangeDto {
@ApiPropertyOptional() @ApiPropertyOptional()
accountId?: string; accountId?: string;
@@ -61,9 +87,6 @@ export class InvestmentDependencyDto {
@ApiProperty() @ApiProperty()
declare dataRevision: string; declare dataRevision: string;
@ApiProperty()
declare stateVersion: string;
@ApiPropertyOptional({ type: String }) @ApiPropertyOptional({ type: String })
unavailableReason?: string; unavailableReason?: string;
@@ -76,14 +99,21 @@ export class InvestmentDependencyDto {
@ApiProperty({ enum: availabilityValues }) @ApiProperty({ enum: availabilityValues })
declare availability: (typeof availabilityValues)[number]; declare availability: (typeof availabilityValues)[number];
@ApiProperty({ enum: completenessValues })
declare completeness: (typeof completenessValues)[number];
@ApiProperty({ enum: freshnessValues }) @ApiProperty({ enum: freshnessValues })
declare freshness: (typeof freshnessValues)[number]; declare freshness: (typeof freshnessValues)[number];
@ApiProperty({ type: String, format: 'date-time', nullable: true }) @ApiProperty({ type: String, format: 'date-time', nullable: true })
declare lastSuccessfulAt: null | string; declare lastSuccessfulAt: null | string;
@ApiProperty({ type: InvestmentSyncDto }) @ApiProperty({ type: InvestmentAttemptDto })
declare sync: InvestmentSyncDto; declare lastAttempt: InvestmentAttemptDto;
// Internal resource-source work remains available to existing source owners.
// It is deliberately undecorated and therefore not part of the HTTP schema.
declare sync: { errorCode: null | string; id: null | string; state: 'failed' | 'idle' | 'running' };
} }
export class InvestmentResourceMetaDto extends InvestmentDependencyDto { export class InvestmentResourceMetaDto extends InvestmentDependencyDto {
@@ -1,4 +1,9 @@
jest.mock('../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { investmentResourceMeta } from './investment-resource.mapper'; import { investmentResourceMeta } from './investment-resource.mapper';
import { hourlyAttempt } from './tbank-sync/tbank-sync.repository';
describe('investment resource source freshness', () => { describe('investment resource source freshness', () => {
afterEach(() => jest.useRealTimers()); afterEach(() => jest.useRealTimers());
@@ -29,7 +34,6 @@ describe('investment resource source freshness', () => {
const stale = investmentResourceMeta(input); const stale = investmentResourceMeta(input);
expect(stale.freshness).toBe('stale'); expect(stale.freshness).toBe('stale');
expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt); expect(stale.lastSuccessfulAt).toBe(fresh.lastSuccessfulAt);
expect(stale.stateVersion).not.toBe(fresh.stateVersion);
expect( expect(
investmentResourceMeta({ investmentResourceMeta({
...input, ...input,
@@ -37,6 +41,105 @@ describe('investment resource source freshness', () => {
}).freshness, }).freshness,
).toBe('unknown'); ).toBe('unknown');
}); });
it('keeps confirmed data state independent from a failed hourly attempt', () => {
jest.useFakeTimers().setSystemTime(new Date('2026-09-25T11:30:00Z'));
const meta = investmentResourceMeta({
resourceKey: 'portfolio/a',
dataRevision: '1',
availability: 'complete',
fetchedAt: new Date('2026-09-25T11:00:00Z'),
freshForMs: 3_600_000,
work: null,
lastAttempt: {
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
state: 'partial',
problems: [
{
section: 'portfolio',
accountId: 'a',
errorCode: 'TBANK_UNAVAILABLE',
},
],
},
});
expect(meta).toMatchObject({
availability: 'available',
completeness: 'complete',
freshness: 'fresh',
lastSuccessfulAt: '2026-09-25T11:00:00.000Z',
lastAttempt: {
state: 'partial',
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
},
});
});
it('projects only the matching hourly section into a resource attempt', () => {
const attempt = hourlyAttempt(
{
startedAt: new Date('2026-09-25T12:00:00Z'),
finishedAt: new Date('2026-09-25T12:01:00Z'),
state: 'partial',
sections: [
{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' },
{ section: 'operations', accountId: 'a', errorCode: null },
{ section: 'events', errorCode: 'CALENDAR_UNAVAILABLE' },
],
},
'portfolio',
'a',
);
const meta = investmentResourceMeta({
resourceKey: 'portfolio/a',
dataRevision: '1',
availability: 'complete',
fetchedAt: new Date('2026-09-25T11:00:00Z'),
freshForMs: 3_600_000,
work: { id: 'legacy-work', state: 'failed', errorCode: 'LEGACY_FAILURE' },
lastAttempt: attempt,
});
expect(meta).toMatchObject({
availability: 'available',
completeness: 'complete',
lastAttempt: {
state: 'partial',
startedAt: '2026-09-25T12:00:00.000Z',
finishedAt: '2026-09-25T12:01:00.000Z',
problems: [{ section: 'portfolio', accountId: 'a', errorCode: 'TBANK_UNAVAILABLE' }],
},
});
expect(JSON.stringify(meta)).not.toContain('legacy-work');
});
it('projects no confirmed result as incomplete without inventing a successful attempt', () => {
expect(
investmentResourceMeta({
resourceKey: 'accounts',
dataRevision: '0',
availability: 'missing',
fetchedAt: null,
freshForMs: 3_600_000,
work: { id: 'work', state: 'running', errorCode: null },
}),
).toMatchObject({
availability: 'initial-loading',
completeness: 'incomplete',
freshness: 'unknown',
lastSuccessfulAt: null,
lastAttempt: {
state: 'running',
startedAt: null,
finishedAt: null,
problems: [],
},
});
});
}); });
describe('dependent resource lifecycle', () => { describe('dependent resource lifecycle', () => {
@@ -61,15 +164,14 @@ describe('dependent resource lifecycle', () => {
...input, ...input,
dependencies: [failed, running], dependencies: [failed, running],
}); });
expect(parent.sync).toEqual(running.sync); expect(parent.lastAttempt.state).toBe('none');
expect(parent.availability).toBe('complete'); expect(parent.availability).toBe('available');
const terminal = investmentResourceMeta({ const terminal = investmentResourceMeta({
...input, ...input,
dependencies: [failed], dependencies: [failed],
}); });
expect(terminal.sync).toEqual(failed.sync); expect(terminal.sync).toEqual(failed.sync);
expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt); expect(terminal.lastSuccessfulAt).toBe(parent.lastSuccessfulAt);
expect(terminal.stateVersion).not.toBe(parent.stateVersion);
expect( expect(
investmentResourceMeta({ investmentResourceMeta({
...input, ...input,
@@ -1,44 +1,34 @@
import { createHash } from 'node:crypto';
import { InvestmentResourceMetaDto } from './investment-resource.dto'; import { InvestmentResourceMetaDto } from './investment-resource.dto';
type LegacyAvailability = 'available' | 'complete' | 'connection-unavailable' | 'initial-loading' | 'missing' | 'partial' | 'temporarily-unavailable';
type Work = null | { errorCode: null | string; id: string; state: string };
type Attempt = NonNullable<InvestmentResourceMetaDto['lastAttempt']>;
export function investmentResourceMeta(input: { export function investmentResourceMeta(input: {
availability: InvestmentResourceMetaDto['availability']; availability: LegacyAvailability;
coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>; coverage?: NonNullable<InvestmentResourceMetaDto['coverage']>;
dataRevision: string; dataRevision: string;
dependencies?: InvestmentResourceMetaDto[]; dependencies?: InvestmentResourceMetaDto[];
fetchedAt: Date | null; fetchedAt: Date | null;
freshForMs: number; freshForMs: number;
lastAttempt?: Attempt;
resourceKey: string; resourceKey: string;
unavailableReason?: string; unavailableReason?: string;
work: null | { errorCode: null | string; id: string; state: string }; work: Work;
}): InvestmentResourceMetaDto { }): InvestmentResourceMetaDto {
const ownSync = { const ownSync = sourceSync(input.work);
id: input.work?.id ?? null, const syncStates = [
state:
input.work?.state === 'running'
? ('running' as const)
: input.work?.state === 'failed'
? ('failed' as const)
: ('idle' as const),
errorCode: input.work?.errorCode ?? null,
};
const states = [
ownSync, ownSync,
...(input.dependencies ?? []) ...(input.dependencies ?? [])
.filter((dependency) => !dependency.unavailableReason) .filter((dependency) => !dependency.unavailableReason)
.map((dependency) => dependency.sync), .map((dependency) => dependency.sync)
.filter((state): state is typeof ownSync => Boolean(state)),
]; ];
const sync = const sourceWork =
states.find((state) => state.state === 'running') ?? syncStates.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'queued') ?? syncStates.find((state) => state.state === 'failed') ??
states.find((state) => state.state === 'failed') ??
ownSync; ownSync;
const sourceFreshness = input.fetchedAt const sourceFreshness = freshnessOf(input.fetchedAt, input.freshForMs);
? Date.now() - input.fetchedAt.valueOf() < input.freshForMs
? ('fresh' as const)
: ('stale' as const)
: ('unknown' as const);
const freshness = [ const freshness = [
sourceFreshness, sourceFreshness,
...(input.dependencies ?? []) ...(input.dependencies ?? [])
@@ -60,30 +50,75 @@ export function investmentResourceMeta(input: {
? { unavailableReason: input.unavailableReason } ? { unavailableReason: input.unavailableReason }
: {}), : {}),
dataRevision: input.dataRevision, dataRevision: input.dataRevision,
availability: input.availability, availability: availability(input.availability, input.work),
freshness: freshness.includes('unknown') completeness:
? ('unknown' as const) availability(input.availability, input.work) === 'available'
: freshness.includes('stale') ? ('complete' as const)
? ('stale' as const) : ('incomplete' as const),
: ('fresh' as const), freshness: combinedFreshness(freshness),
lastSuccessfulAt: timestamps[0] ?? null, lastSuccessfulAt: timestamps[0] ?? null,
sync, lastAttempt: input.lastAttempt ??
attempt(input.work),
...(input.coverage ? { coverage: input.coverage } : {}), ...(input.coverage ? { coverage: input.coverage } : {}),
...(input.dependencies ? { dependencies: input.dependencies } : {}), ...(input.dependencies ? { dependencies: input.dependencies } : {}),
}; };
return { return Object.defineProperty(meta, 'sync', {
...meta, enumerable: false,
stateVersion: createHash('sha256') value: sourceWork,
.update( }) as InvestmentResourceMetaDto;
JSON.stringify({ }
...meta,
// The requested window is identified by resourceKey. Its moving "now" function sourceSync(work: Work) {
// boundary is not a change to saved coverage between status and GET. let state: 'failed' | 'idle' | 'running' = 'idle';
coverage: meta.coverage if (work?.state === 'running') state = 'running';
? { ...meta.coverage, requested: undefined } else if (work?.state === 'failed') state = 'failed';
: undefined, return { id: work?.id ?? null, state, errorCode: work?.errorCode ?? null };
}), }
)
.digest('base64url'), function freshnessOf(fetchedAt: Date | null, freshForMs: number) {
}; if (!fetchedAt) return 'unknown' as const;
return Date.now() - fetchedAt.valueOf() < freshForMs
? ('fresh' as const)
: ('stale' as const);
}
function combinedFreshness(values: InvestmentResourceMetaDto['freshness'][]) {
if (values.includes('unknown')) return 'unknown' as const;
if (values.includes('stale')) return 'stale' as const;
return 'fresh' as const;
}
function attempt(work: Work): Attempt {
const base = { startedAt: null, finishedAt: null };
if (work?.state === 'running') return { ...base, state: 'running', problems: [] };
if (work?.state === 'failed') {
return {
...base,
state: 'failed',
problems: work.errorCode
? [{ section: 'membership', accountId: null, errorCode: work.errorCode }]
: [],
};
}
return { ...base, state: 'none', problems: [] };
}
function availability(value: LegacyAvailability, work: Work) {
if (
value === 'available' ||
value === 'connection-unavailable' ||
value === 'initial-loading' ||
value === 'temporarily-unavailable'
) {
return value;
}
if (value === 'complete') return 'available' as const;
if (value === 'partial') {
return work?.state === 'running'
? ('initial-loading' as const)
: ('temporarily-unavailable' as const);
}
return work?.state === 'running'
? ('initial-loading' as const)
: ('temporarily-unavailable' as const);
} }
@@ -267,25 +267,6 @@ export function planInvestmentScreen(
priority: 'screen', priority: 'screen',
}); });
} }
if (
!force &&
['account', 'events', 'income', 'operations'].includes(screen.screen)
) {
if (instrument.historyFrom) {
sources.push({
...identity,
kind: 'candles',
from: instrument.historyFrom,
to: now,
priority: 'history',
});
} else {
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
}
} }
if (!force && !['bonds', 'stocks'].includes(screen.screen)) { if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
sources.push( sources.push(
@@ -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,
@@ -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,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { tbankSourceNeedsRefresh } from '../../../../integrations/tbank/tbank.api'; import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
ForecastRepository, ForecastRepository,
@@ -10,7 +10,6 @@ import {
} from '../infrastructure/forecast.repository'; } from '../infrastructure/forecast.repository';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@Injectable() @Injectable()
export class ForecastService { export class ForecastService {
@@ -18,6 +17,7 @@ export class ForecastService {
async result<T extends object>(input: { async result<T extends object>(input: {
candidate?: T; candidate?: T;
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
membershipRevision: number; membershipRevision: number;
portfolio: InvestmentResourceMetaDto; portfolio: InvestmentResourceMetaDto;
revision: string; revision: string;
@@ -73,41 +73,52 @@ export class ForecastService {
dependencies dependencies
.map((dependency) => dependency.sync) .map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed'); .find((sync) => sync.state === 'failed');
const payload = stored ? (stored.payload as T) : null;
const data =
payload &&
'positionIsStale' in payload &&
'positionAt' in payload &&
typeof payload.positionAt === 'string'
? {
...payload,
positionIsStale: tbankSourceNeedsRefresh(
'portfolio',
new Date(payload.positionAt),
new Date(Date.now()),
),
}
: payload;
return { return {
data, data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: input.scopeKey, resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
availability: stored availability: stored
? 'complete' ? 'complete'
: dependencies.some( : dependencies.some(
(dependency) => dependency.availability !== 'missing', (dependency) => dependency.completeness === 'complete',
) )
? 'partial' ? 'partial'
: 'missing', : 'missing',
fetchedAt: first ? new Date(first) : null, fetchedAt: first ? new Date(first) : null,
freshForMs: freshForMs: Number.POSITIVE_INFINITY,
data && 'positionIsStale' in data && data.positionIsStale === true
? STALE_FRESHNESS_MS
: Number.POSITIVE_INFINITY,
work: work?.id ? { ...work, id: work.id } : null, work: work?.id ? { ...work, id: work.id } : null,
lastAttempt: input.lastAttempt,
dependencies, dependencies,
}), }),
}; };
} }
} }
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
if (!scopeKey.startsWith('events/')) return payload;
try {
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
const value = payload as T & {
events?: { date: string; projectedAmount?: { currency: string; value: string }; type: string }[];
incomeTotalsByCurrency?: { currency: string; value: string }[];
};
if (!value.events || !value.incomeTotalsByCurrency) return payload;
const events = value.events.filter((event) => event.date >= from && event.date <= to);
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) continue;
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
}
return {
...value,
events,
incomeTotalsByCurrency: [...totals]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, amount]) => ({ currency, value: formatDecimal(amount) })),
};
} catch {
return payload;
}
}
@@ -1,4 +1,3 @@
import { ServiceUnavailableException } from '@nestjs/common';
import { setImmediate as immediate } from 'node:timers/promises'; import { setImmediate as immediate } from 'node:timers/promises';
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
@@ -23,12 +22,11 @@ describe('persistent portfolio projection', () => {
service = new PortfolioService( service = new PortfolioService(
access as never, access as never,
tbank as never, tbank as never,
{ set: jest.fn() } as never,
metadata as never, metadata as never,
repository as never, repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never, { request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never, { event: jest.fn() } as never,
{ prefetch: jest.fn().mockResolvedValue(undefined) } as never, { hourly: jest.fn().mockResolvedValue(null) } as never,
); );
access.local.mockResolvedValue({ access.local.mockResolvedValue({
revision: 'private', revision: 'private',
@@ -43,6 +41,7 @@ describe('persistent portfolio projection', () => {
accounts: [], accounts: [],
work: [], work: [],
}); });
repository.publish.mockResolvedValue(true);
metadata.savedMany.mockResolvedValue(new Map()); metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null); metadata.request.mockResolvedValue(null);
}); });
@@ -98,7 +97,7 @@ describe('persistent portfolio projection', () => {
], ],
work: [], work: [],
}); });
await service.read('user', 'a'); await service.request('user', 'private', 'a');
for ( for (
let index = 0; let index = 0;
index < 10 && !repository.publish.mock.calls.length; index < 10 && !repository.publish.mock.calls.length;
@@ -192,10 +191,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,
); );
@@ -213,7 +213,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' },
]); ]);
@@ -221,15 +221,4 @@ describe('persistent portfolio projection', () => {
expect(complete.data?.isStale).toBe(true); expect(complete.data?.isStale).toBe(true);
expect(tbank.getPortfolio).not.toHaveBeenCalled(); expect(tbank.getPortfolio).not.toHaveBeenCalled();
}); });
it('keeps pending data unavailable to internal financial consumers', async () => {
repository.read.mockResolvedValue({
membershipAt: null,
membershipRevision: 0,
accounts: [],
work: [],
});
await expect(service.getAll('user')).rejects.toBeInstanceOf(
ServiceUnavailableException,
);
});
}); });
@@ -1,16 +1,13 @@
import { import {
Injectable, Injectable,
NotFoundException, NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { RequestContextService } from '../../../../common/logging/request-context.service';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { 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,
@@ -19,8 +16,11 @@ import {
type TbankPortfolio, type TbankPortfolio,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { CandleService } from '../../instruments/instruments.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.repository';
import { portfolioStructure } from '../domain/portfolio-structure'; import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals'; import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import { import {
@@ -32,70 +32,31 @@ 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;
@Injectable() @Injectable()
export class PortfolioService { export class PortfolioService {
private readonly processRunId = randomUUID(); private readonly processRunId = randomUUID();
private readonly active = new Set<string>();
private readonly pending = new Map<string, Promise<void>>(); private readonly pending = new Map<string, Promise<void>>();
private reservations: Promise<unknown> = Promise.resolve(); private reservations: Promise<unknown> = Promise.resolve();
constructor( constructor(
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly requestContext: RequestContextService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly repository: PortfolioRepository, private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService, private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
private readonly candles: CandleService, private readonly sync: TbankSyncRepository,
) {} ) {}
async saved(userId: string, accountId: string, observe = false) { // Local reads never start upstream work; the hourly walk owns refreshes.
this.requestContext.set({ userId, brokerageAccountId: accountId }); async saved(userId: string, accountId: string) {
const connection = await this.access.local(userId, accountId); const connection = await this.access.local(userId, accountId);
if (!observe) {
const discovery = await this.membership.request(
userId,
connection.revision,
);
if (discovery) {
void discovery.done
.then(async () => {
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
for (const account of current.accounts.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)) {
await this.queueSnapshot(
userId,
current.revision,
account.accountId,
);
}
})
.catch(() =>
this.logger.event(
'warn',
'tbank.portfolio.discovery_not_available',
{ userId },
),
);
}
for (const account of (discovery ? [] : connection.accounts).filter(
(account) => accountId === 'all' || account.accountId === accountId,
)) {
await this.queueSnapshot(
userId,
connection.revision,
account.accountId,
);
}
}
const saved = await this.repository.read(userId, connection.revision); const saved = await this.repository.read(userId, connection.revision);
const hourly = await this.sync.hourly(userId);
if ( if (
saved?.membershipAt && saved?.membershipAt &&
accountId !== 'all' && accountId !== 'all' &&
@@ -103,48 +64,6 @@ export class PortfolioService {
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
if (!observe) {
const identities = new Map<string, MetadataIdentity>();
for (const account of (saved?.accounts ?? []).filter(
(account) => accountId === 'all' || account.accountId === accountId,
)) {
const snapshot = account.portfolio?.payload as unknown as
PortfolioDto | undefined;
for (const position of snapshot?.positions ?? []) {
identities.set(metadataKey(position), position);
}
}
const savedMetadata = await this.metadata.savedMany([
...identities.values(),
]);
for (const identity of identities.values()) {
if (!savedMetadata.get(metadataKey(identity))?.fresh) {
await this.metadata.request(userId, connection.revision, identity);
}
}
}
if (!observe) {
void this.candles
.prefetch(
userId,
connection.revision,
(saved?.accounts ?? [])
.filter(
(account) =>
accountId === 'all' || account.accountId === accountId,
)
.flatMap(
(account) =>
(
account.portfolio?.payload as unknown as
PortfolioDto | undefined
)?.positions ?? [],
),
)
.catch(() =>
this.logger.event('warn', 'portfolio.candle-prefetch-unavailable'),
);
}
const membershipMeta = investmentResourceMeta({ const membershipMeta = investmentResourceMeta({
resourceKey: 'accounts/membership', resourceKey: 'accounts/membership',
dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION), dataRevision: String(saved?.membershipRevision ?? MISSING_DATA_REVISION),
@@ -152,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(
@@ -183,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 ?? []),
); );
@@ -205,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,
}), }),
] ]
@@ -241,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] ??
@@ -284,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),
@@ -319,7 +247,7 @@ export class PortfolioService {
revision, revision,
accountId, accountId,
this.processRunId, this.processRunId,
this.active, this.pending,
force, force,
); );
if (!requested) return null; if (!requested) return null;
@@ -330,7 +258,6 @@ export class PortfolioService {
}; };
} }
const { id, attemptId } = requested.work; const { id, attemptId } = requested.work;
this.active.add(attemptId);
const done = new Promise<void>((resolve) => setImmediate(resolve)) const done = new Promise<void>((resolve) => setImmediate(resolve))
.then(async () => { .then(async () => {
const attempt = { id, attemptId, userId, revision }; const attempt = { id, attemptId, userId, revision };
@@ -354,13 +281,6 @@ export class PortfolioService {
for (const position of snapshot.positions) { for (const position of snapshot.positions) {
await this.metadata.request(userId, revision, position); await this.metadata.request(userId, revision, position);
} }
if (!force) {
await this.candles.prefetch(
userId,
revision,
snapshot.positions,
);
}
} }
} catch (error) { } catch (error) {
if ( if (
@@ -377,7 +297,6 @@ export class PortfolioService {
}), }),
) )
.finally(() => { .finally(() => {
this.active.delete(attemptId);
this.pending.delete(attemptId); this.pending.delete(attemptId);
}); });
this.pending.set(attemptId, done); this.pending.set(attemptId, done);
@@ -387,18 +306,6 @@ export class PortfolioService {
return reserved; return reserved;
} }
async get(userId: string, accountId: string): Promise<PortfolioDto> {
const result = await this.read(userId, accountId);
if (!result.data) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
}
return result.data;
}
getAll(userId: string): Promise<PortfolioDto> {
return this.get(userId, 'all');
}
private async normalize( private async normalize(
upstream: NonNullable<TbankPortfolio['positions']>, upstream: NonNullable<TbankPortfolio['positions']>,
money: TbankMoney[], money: TbankMoney[],
@@ -44,24 +44,39 @@ export class ForecastRepository {
data: { membershipRevision: { increment: 0 } }, data: { membershipRevision: { increment: 0 } },
}); });
if (!current.count) return null; if (!current.count) return null;
} } else if (!(await tx.tbankConnection.findFirst({
const connection = await tx.tbankConnection.findFirst({ where: { ...owner, membershipRevision: input.membershipRevision, invalidToken: false },
where: { }))) return null;
...owner,
membershipRevision: input.membershipRevision,
invalidToken: false,
},
});
if (!connection) return null;
const key = { ...owner, scopeKey: input.scopeKey }; const key = { ...owner, scopeKey: input.scopeKey };
const previous = await tx.tbankForecast.findUnique({ const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key }, where: { userId_revision_scopeKey: key },
}); });
const requestedWindow = eventsWindow(input.scopeKey);
const saved = previous ?? (requestedWindow
? (
await tx.tbankForecast.findMany({
where: {
...owner,
scopeKey: { startsWith: 'events/' },
membershipRevision: input.membershipRevision,
},
})
).find((forecast) => {
const window = eventsWindow(forecast.scopeKey);
return (
window &&
window.accountId === requestedWindow.accountId &&
window.from <= requestedWindow.to &&
requestedWindow.from <= window.to
);
})
: null);
const fallback = const fallback =
previous?.membershipRevision === input.membershipRevision saved?.membershipRevision === input.membershipRevision
? previous ? saved
: null; : null;
if (!input.candidate) return fallback; if (!input.candidate) return fallback;
if (!previous && fallback) return fallback;
const portfolios = await tx.tbankPortfolio.findMany({ const portfolios = await tx.tbankPortfolio.findMany({
where: { where: {
...owner, ...owner,
@@ -160,3 +175,13 @@ export class ForecastRepository {
}); });
} }
} }
function eventsWindow(scopeKey: string) {
if (!scopeKey.startsWith('events/')) return null;
try {
const [accountId, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
return { accountId, from, to };
} catch {
return null;
}
}
@@ -52,7 +52,7 @@ export class PortfolioRepository {
revision: string, revision: string,
accountId: string, accountId: string,
processRunId: string, processRunId: string,
active: ReadonlySet<string>, pending: ReadonlyMap<string, Promise<void>>,
force: boolean, force: boolean,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
@@ -69,21 +69,27 @@ export class PortfolioRepository {
const snapshot = await tx.tbankPortfolio.findUnique({ const snapshot = await tx.tbankPortfolio.findUnique({
where: { userId_revision_accountId: { userId, revision, accountId } }, where: { userId_revision_accountId: { userId, revision, accountId } },
}); });
const portfolioWorkScope = {
userId,
revision,
scopeKey: portfolioWorkKey(accountId),
};
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: portfolioWorkScope },
});
if ( if (
!force && !force &&
snapshot && snapshot &&
!tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) !tbankSourceNeedsRefresh('portfolio', snapshot.fetchedAt, new Date()) &&
previous?.state !== 'failed'
) { ) {
return null; return null;
} }
const scope = { userId, revision, scopeKey: portfolioWorkKey(accountId) }; const scope = portfolioWorkScope;
const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope },
});
if ( if (
previous?.state === 'running' && previous?.state === 'running' &&
previous.processRunId === processRunId && previous.processRunId === processRunId &&
active.has(previous.attemptId) pending.has(previous.attemptId)
) { ) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
@@ -139,6 +145,41 @@ export class PortfolioRepository {
create: { ...scope, ...data }, create: { ...scope, ...data },
update: { ...data, dataRevision: { increment: 1 } }, update: { ...data, dataRevision: { increment: 1 } },
}); });
// Value history points require a fully valued snapshot; the attempt
// identity deduplicates repeated or late publications of one attempt.
if (
snapshot.totalsByCurrency.length > Number.MIN_VALUE &&
snapshot.positions.every((position) => position.currentValue !== null)
) {
await tx.tbankPortfolioValueHistory.upsert({
where: {
userId_accountId: { userId: attempt.userId, accountId },
},
create: { userId: attempt.userId, accountId },
update: { deletedAt: null },
});
for (const { currency, value } of snapshot.totalsByCurrency) {
await tx.tbankPortfolioValuePoint.upsert({
where: {
userId_accountId_attemptId_currency: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
},
},
create: {
userId: attempt.userId,
accountId,
attemptId: attempt.attemptId,
currency,
value,
capturedAt: data.fetchedAt,
},
update: {},
});
}
}
return true; return true;
}); });
} }
@@ -2,7 +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 { InstrumentSourcesModule } from '../instruments/instrument-sources.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';
@@ -10,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, InstrumentSourcesModule], 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,7 +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 { InvestmentResourceMetaDto } from '../investment-resource.dto'; 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;
@@ -216,11 +216,6 @@ export class TbankSyncAcceptedDto {
@ApiProperty({ format: 'date-time' }) declare now: string; @ApiProperty({ format: 'date-time' }) declare now: string;
} }
export class TbankSyncProgressDto {
@ApiProperty() declare completed: number;
@ApiProperty() declare total: number;
@ApiProperty() declare discovering: boolean;
}
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 })
@@ -232,10 +227,47 @@ export class TbankSyncStatusDto {
@ApiProperty({ type: String, nullable: true }) declare errorCode: @ApiProperty({ type: String, nullable: true }) declare errorCode:
null | string; null | string;
}
@ApiProperty({ type: TbankSyncProgressDto }) export class HourlySyncSectionDto {
declare progress: TbankSyncProgressDto; @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@ApiProperty({ type: InvestmentResourceMetaDto, isArray: true }) @ApiPropertyOptional({
declare resources: InvestmentResourceMetaDto[]; type: String,
nullable: false,
description: 'Account identifier of the problematic section, when scoped.',
})
declare accountId?: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Safe error code of the failed section. No tokens or upstream payloads.',
})
declare errorCode: 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[];
} }
@@ -2,15 +2,13 @@ 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 { BrokerageAccountsModule } from '../brokerage-accounts/brokerage-accounts.module';
import { IncomeModule } from '../income/income.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { InstrumentsModule } from '../instruments/instruments.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 { 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: [
@@ -19,12 +17,11 @@ import { TbankSyncService } from './tbank-sync.service';
PortfolioModule, PortfolioModule,
OperationsModule, OperationsModule,
InstrumentSourcesModule, InstrumentSourcesModule,
InstrumentsModule,
BrokerageAccountsModule,
IncomeModule,
UpcomingEventsModule, UpcomingEventsModule,
TbankSyncRepositoryModule,
], ],
controllers: [TbankSyncController], controllers: [TbankSyncController],
providers: [TbankSyncService, TbankSyncRepository], providers: [TbankSyncService, TbankHourlyScheduler],
exports: [TbankHourlyScheduler],
}) })
export class TbankSyncModule {} export class TbankSyncModule {}
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto'; 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,61 @@ 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'],
accountId?: string,
): InvestmentAttemptDto {
if (!hourly) {
return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
}
const problems = hourly.sections
.filter(
(entry) =>
entry.section === 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) {}
@@ -27,7 +83,7 @@ export class TbankSyncRepository {
data: { membershipRevision: { increment: 0 } }, data: { membershipRevision: { increment: 0 } },
}); });
if (!valid.count) return null; if (!valid.count) return null;
const scopeKey = `screen:${createHash('sha256').update(JSON.stringify(scope)).digest('hex')}`; const scopeKey = `screen:${JSON.stringify(scope)}`;
const key = { userId, revision, scopeKey }; const key = { userId, revision, scopeKey };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: key }, where: { userId_revision_scopeKey: key },
@@ -123,4 +179,238 @@ 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.
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) {
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
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;
}
} }
@@ -6,8 +6,6 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import { import {
@@ -16,26 +14,17 @@ import {
TbankMembershipService, TbankMembershipService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api'; } from '../../../integrations/tbank/tbank.api';
import { BrokerageAccountsService } from '../brokerage-accounts/brokerage-accounts-read.api';
import { IncomeAnalyticsService } from '../income/income-read.api';
import {
BondIncomeService,
StockIncomeService,
} from '../instruments/instrument-income.api';
import { import {
CandleService, CandleService,
CatalogDiscoveryService, CatalogDiscoveryService,
CatalogEnrichmentService, CatalogEnrichmentService,
} from '../instruments/instruments.api'; } from '../instruments/instruments.api';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { planInvestmentScreen } from '../investment-sync-plan'; import { planInvestmentScreen } from '../investment-sync-plan';
import { import {
BrokerageOperationsService,
BrokerageOperationsSyncService, BrokerageOperationsSyncService,
type OperationInterval, type OperationInterval,
} from '../operations/operations.api'; } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events-read.api';
import { normalizeScreen } from './tbank-screen'; import { normalizeScreen } from './tbank-screen';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto'; import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import { import {
@@ -44,9 +33,6 @@ import {
} from './tbank-sync.repository'; } from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0; const FOREGROUND_CANDLE_PRIORITY = 0;
const EXCLUSIVE_END_OFFSET_MS = 1;
const PENDING_CHILD_COUNT = 0;
const COMPLETED_CHILD_COUNT = 1;
@Injectable() @Injectable()
export class TbankSyncService { export class TbankSyncService {
@@ -59,16 +45,10 @@ export class TbankSyncService {
private readonly membership: TbankMembershipService, private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService, private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService, private readonly operations: BrokerageOperationsSyncService,
private readonly ledger: BrokerageOperationsService,
private readonly metadata: InstrumentMetadataService, private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService, private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService, private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService, private readonly candles: CandleService,
private readonly accounts: BrokerageAccountsService,
private readonly income: IncomeAnalyticsService,
private readonly events: UpcomingEventsService,
private readonly stockIncome: StockIncomeService,
private readonly bondIncome: BondIncomeService,
) {} ) {}
async request(userId: string, input: InvestmentScreenDto) { async request(userId: string, input: InvestmentScreenDto) {
@@ -139,7 +119,6 @@ export class TbankSyncService {
scope.screen === 'stock' || scope.screen === 'bond' scope.screen === 'stock' || scope.screen === 'bond'
? 'all' ? 'all'
: accountScope, : accountScope,
true,
); );
const positions = saved.data?.positions ?? []; const positions = saved.data?.positions ?? [];
const instruments = [ const instruments = [
@@ -368,7 +347,6 @@ export class TbankSyncService {
: children.some((child) => child.state === 'cancelled') : children.some((child) => child.state === 'cancelled')
? 'cancelled' ? 'cancelled'
: 'succeeded'; : 'succeeded';
const resources = await this.observe(userId, intent.scope);
if ( if (
(await this.access.local(userId, intent.scope.accountId)).revision !== (await this.access.local(userId, intent.scope.accountId)).revision !==
connection.revision connection.revision
@@ -380,12 +358,6 @@ export class TbankSyncService {
...intent, ...intent,
state, state,
errorCode: failed, errorCode: failed,
progress: {
completed: children.filter((child) => child.state !== 'running').length,
total: children.length,
discovering: !saved.progress.dispatched,
},
resources,
}; };
} }
@@ -393,106 +365,6 @@ export class TbankSyncService {
userId: string, userId: string,
work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'], work: NonNullable<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> { ): Promise<TbankSyncStatusDto> {
const resources: InvestmentResourceMetaDto[] = [];
const input = work.intent as Record<string, unknown>;
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
if (work.scopeKey === 'membership') {
await add(this.accounts.list(userId, true));
} else if (
input.source === 'portfolio' &&
typeof input.accountId === 'string'
) {
await add(this.portfolio.read(userId, input.accountId, true));
} else if (input.source === 'catalog') {
const type = input.instrumentType === 'bond' ? 'bond' : 'share';
if (typeof input.query === 'string') {
await add(
this.discovery.search(
userId,
{
instrumentType: type,
query: input.query,
exactTicker: input.exactTicker === true,
},
true,
),
);
} else if (
typeof input.uid === 'string' &&
typeof input.kind === 'string'
) {
const range =
typeof input.rangeKey === 'string' && input.rangeKey.startsWith('[')
? (JSON.parse(input.rangeKey) as string[])
: null;
const sources = await this.sources.readMany(
userId,
type,
[input.uid],
true,
[
input.kind as Parameters<
CatalogEnrichmentService['readMany']
>[4] extends (infer K)[] | undefined
? K
: never,
],
range ? { from: range[0], to: range[1] } : undefined,
);
resources.push(...sources.map((source) => source.meta));
}
} else if (input.source === 'metadata') {
const identity = {
instrumentUid:
typeof input.instrumentUid === 'string' ? input.instrumentUid : null,
figi: typeof input.figi === 'string' ? input.figi : null,
};
const source = await this.metadata.read(userId, identity, true);
resources.push(
investmentResourceMeta({
resourceKey: work.scopeKey,
dataRevision: source?.dataRevision ?? '0',
availability: source?.complete ? 'complete' : 'missing',
fetchedAt: source?.fetchedAt ?? null,
freshForMs: Number.POSITIVE_INFINITY,
work,
}),
);
} else if (
work.scopeKey.startsWith('candles:') &&
typeof input.uid === 'string'
) {
await add(
this.candles.read(
userId,
{
uid: input.uid,
instrumentType: input.instrumentType === 'bond' ? 'bond' : 'share',
period: input.period as 'all' | 'halfYear' | 'month' | 'year',
},
true,
),
);
} else if (
work.scopeKey.startsWith('operations:') &&
Array.isArray(input.intervals)
) {
for (const key of input.intervals as string[]) {
const [accountId, from, end, instrumentUid] = JSON.parse(
key,
) as string[];
await add(
this.ledger.read(userId, accountId, {
from,
to: new Date(Date.parse(end) - EXCLUSIVE_END_OFFSET_MS).toISOString(),
...(instrumentUid ? { instrumentUid } : {}),
sync: 'observe',
}),
);
}
}
const observed = work.scopeKey.startsWith('operations:') const observed = work.scopeKey.startsWith('operations:')
? await this.operations.observe(userId, work.id) ? await this.operations.observe(userId, work.id)
: work; : work;
@@ -515,112 +387,6 @@ export class TbankSyncService {
now: work.createdAt.toISOString(), now: work.createdAt.toISOString(),
state, state,
errorCode: observed.errorCode, errorCode: observed.errorCode,
progress: {
completed: state === 'running' ? PENDING_CHILD_COUNT : COMPLETED_CHILD_COUNT,
total: 1,
discovering: false,
},
resources,
}; };
} }
private async observe(userId: string, scope: InvestmentScreenDto) {
const resources: InvestmentResourceMetaDto[] = [];
const add = async (read: Promise<{ meta: InvestmentResourceMetaDto }>) => {
resources.push((await read).meta);
};
const account = scope.accountId ?? 'all';
if (scope.screen === 'accounts') {
await add(this.accounts.list(userId, true));
}
if (scope.screen === 'account') {
await add(this.portfolio.read(userId, account, true));
await add(this.income.summary(userId, account, true));
await add(this.events.get(userId, account, { sync: 'observe' }));
}
if (['account', 'bond', 'operations', 'stock'].includes(scope.screen)) {
await add(
this.ledger.read(userId, account, {
from: scope.from,
to: scope.to,
instrumentUid: scope.instrumentUid,
categories: scope.categories,
limit: scope.limit,
sync: 'observe',
}),
);
}
if (scope.screen === 'income') {
await add(
this.income.analytics(userId, account, scope.from, scope.to, true),
);
}
if (scope.screen === 'events') {
await add(
this.events.get(userId, account, {
from: scope.from,
to: scope.to,
sync: 'observe',
}),
);
}
const type =
scope.screen === 'bond' || scope.screen === 'bonds' ? 'bond' : 'share';
if (scope.screen === 'stocks' || scope.screen === 'bonds') {
await add(
this.discovery.search(
userId,
{ instrumentType: type, query: scope.query ?? '' },
true,
),
);
}
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
const uid = scope.instrumentUid;
await add(this.discovery.detail(userId, type, uid, true));
await add(this.sources.section(userId, type, uid, 'quote', true));
await add(
this.sources.section(
userId,
type,
uid,
type === 'share' ? 'company' : 'metrics',
true,
),
);
await add(
this.candles.read(
userId,
{
uid,
instrumentType: type,
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
true,
),
);
await add(
type === 'share'
? this.stockIncome.readPosition(userId, uid, true)
: this.bondIncome.readPosition(userId, uid, true),
);
await add(
type === 'share'
? this.stockIncome.dividends(userId, uid, true)
: this.bondIncome.coupons(userId, uid, true),
);
}
return resources;
}
} }
@@ -1,12 +1,13 @@
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
import { import {
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../common/decimal'; } from '../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time';
import type { MoexBondEvent } from '../../../integrations/moex/moex.api';
import { decimal } from '../../../integrations/tbank/decimal'; import { decimal } from '../../../integrations/tbank/decimal';
import type { TbankDividend } from '../../../integrations/tbank/tbank.api';
import { UpcomingEventDto } from './upcoming-events.dto'; import { UpcomingEventDto } from './upcoming-events.dto';
export function dividendEvent( export function dividendEvent(
@@ -1 +0,0 @@
export { UpcomingEventsService } from './upcoming-events.service';
@@ -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],
@@ -20,12 +20,16 @@ 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 { import {
UpcomingEventDto, UpcomingEventDto,
UpcomingEventsDto, UpcomingEventsDto,
UpcomingEventsQueryDto, UpcomingEventsQueryDto,
} from './upcoming-events.dto'; } from './upcoming-events.dto';
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' } enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
@@ -43,66 +47,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 +104,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 +163,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 +176,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 +280,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
} }
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] { function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimal>>(); const totals = new Map<string, ReturnType<typeof decimalFromString>>();
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;
+1 -1
View File
@@ -81,7 +81,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: [] } } : {}),
}); });
}, },
+27 -17
View File
@@ -1,12 +1,13 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
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 { PinoLogger } from 'nestjs-pino'; import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import request from 'supertest'; import request from 'supertest';
import { App } from 'supertest/types'; import { App } from 'supertest/types';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { StructuredLogger } from '../src/common/logging/structured-logger.service'; 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 { PrismaService } from '../src/prisma/prisma.service'; import { PrismaService } from '../src/prisma/prisma.service';
@@ -16,10 +17,22 @@ type Problem = { code: string };
describe('Authentication (e2e)', () => { describe('Authentication (e2e)', () => {
let app: INestApplication<App>; let app: INestApplication<App>;
const logs: Record<string, unknown>[] = [];
beforeEach(async () => { beforeEach(async () => {
logs.length = 0;
const moduleFixture: TestingModule = await Test.createTestingModule({ const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule], imports: [AppModule],
}).overrideProvider(PARAMS_PROVIDER_TOKEN).useFactory({
inject: [ConfigService],
factory: (config: Parameters<typeof loggingOptions>[0]) => ({
pinoHttp: {
...loggingOptions(config),
level: 'info',
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
},
assignResponse: true,
}),
}).compile(); }).compile();
app = moduleFixture.createNestApplication(); app = moduleFixture.createNestApplication();
@@ -187,10 +200,12 @@ describe('Authentication (e2e)', () => {
]); ]);
const winner = responses.find((response) => response.status === 200); const winner = responses.find((response) => response.status === 200);
if (!winner) throw new Error('Expected one successful rotation'); if (!winner) throw new Error('Expected one successful rotation');
expect(responses.find((response) => response.status === 401)?.headers['set-cookie']).toBeUndefined();
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/v1/auth/refresh') .post('/api/v1/auth/refresh')
.set('Cookie', cookie) .set('Cookie', cookie)
.expect(401); .expect(401)
.expect((response) => expect(response.headers['set-cookie']).toBeUndefined());
await request(app.getHttpServer()) await request(app.getHttpServer())
.post('/api/v1/auth/refresh') .post('/api/v1/auth/refresh')
.set('Cookie', winner.headers['set-cookie'][0]) .set('Cookie', winner.headers['set-cookie'][0])
@@ -304,8 +319,6 @@ describe('Authentication (e2e)', () => {
}); });
it('exposes a server request ID and logs one completion event', async () => { it('exposes a server request ID and logs one completion event', async () => {
const logger = app.get(StructuredLogger);
const event = jest.spyOn(logger, 'event');
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.get('/api/v1/auth/me') .get('/api/v1/auth/me')
.set('Origin', 'http://localhost:5173') .set('Origin', 'http://localhost:5173')
@@ -315,16 +328,12 @@ describe('Authentication (e2e)', () => {
expect(response.headers['access-control-expose-headers']).toContain( expect(response.headers['access-control-expose-headers']).toContain(
'X-Request-ID', 'X-Request-ID',
); );
expect(event).toHaveBeenCalledTimes(1); expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
expect(event).toHaveBeenCalledWith( expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
'warn', ]);
'http.request.completed',
expect.objectContaining({ status: 401, code: 'UNAUTHORIZED' }),
);
}); });
it('logs an unhandled error with safe public context and correlation', async () => { it('logs an unhandled error with safe public context and correlation', async () => {
const error = jest.spyOn(PinoLogger.prototype, 'error');
const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43'; const clientRequestId = 'a2dc6fbb-5a48-4c3c-9e3e-4fbf4d638a43';
jest jest
.spyOn(app.get(AuthService), 'login') .spyOn(app.get(AuthService), 'login')
@@ -342,17 +351,18 @@ describe('Authentication (e2e)', () => {
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }), expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
); );
expect(error).toHaveBeenCalledTimes(1); const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
expect(error).toHaveBeenCalledWith( expect(completed).toEqual([
expect.objectContaining({ expect.objectContaining({
requestId: response.headers['x-request-id'], requestId: response.headers['x-request-id'],
clientRequestId, clientRequestId,
status: 500, status: 500,
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.completed',
}), }),
'http.request.completed', ]);
); expect(JSON.stringify(logs)).not.toContain(
expect(JSON.stringify(error.mock.calls)).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');
+2 -26
View File
@@ -27,32 +27,8 @@ describe('OpenAPI contract (integration)', () => {
it('distinguishes calendar screen dates from operation instants in requests and responses', () => { it('distinguishes calendar screen dates from operation instants in requests and responses', () => {
const document = createOpenApiDocument(app); const document = createOpenApiDocument(app);
const schemas = document.components?.schemas; const schemas = document.components?.schemas;
const screenSchema = { expect(document.paths['/api/v1/tbank-sync']).toBeUndefined();
oneOf: [ expect(schemas?.TbankSyncStatusDto).toBeDefined();
{ $ref: '#/components/schemas/IncomeInvestmentScreenDto' },
{ $ref: '#/components/schemas/EventsInvestmentScreenDto' },
{ $ref: '#/components/schemas/TimestampInvestmentScreenDto' },
],
discriminator: {
propertyName: 'screen',
mapping: {
income: '#/components/schemas/IncomeInvestmentScreenDto',
events: '#/components/schemas/EventsInvestmentScreenDto',
operations: '#/components/schemas/TimestampInvestmentScreenDto',
},
},
};
expect(
document.paths['/api/v1/tbank-sync'].post?.requestBody,
).toMatchObject({
content: { 'application/json': { schema: screenSchema } },
});
expect(schemas?.TbankSyncAcceptedDto).toMatchObject({
properties: { scope: screenSchema },
});
expect(schemas?.TbankSyncStatusDto).toMatchObject({
properties: { scope: { ...screenSchema, nullable: true } },
});
for (const [model, screen] of [ for (const [model, screen] of [
['IncomeInvestmentScreenDto', 'income'], ['IncomeInvestmentScreenDto', 'income'],
['EventsInvestmentScreenDto', 'events'], ['EventsInvestmentScreenDto', 'events'],
+34 -16
View File
@@ -3,16 +3,21 @@ import type { Server } from 'node:http';
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { setTimeout as delay } from 'node:timers/promises';
import request from 'supertest'; import request from 'supertest';
import { AppModule } from '../src/app.module'; import { AppModule } from '../src/app.module';
import { TbankMembershipService } from '../src/integrations/tbank/tbank-membership.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 { PortfolioService } from '../src/modules/investments/portfolio/application/portfolio.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';
describe('Portfolio structure HTTP contract', () => { describe('Portfolio structure HTTP contract', () => {
let app: INestApplication<Server>; let app: INestApplication<Server>;
let prisma: PrismaService;
let membership: TbankMembershipService;
let portfolios: PortfolioService;
const tbank = { const tbank = {
validateToken: jest.fn().mockResolvedValue([ validateToken: jest.fn().mockResolvedValue([
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' }, { id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
@@ -22,6 +27,10 @@ describe('Portfolio structure HTTP contract', () => {
getPositions: jest.fn(), getPositions: jest.fn(),
getInstrumentByFigi: jest.fn().mockResolvedValue(null), getInstrumentByFigi: jest.fn().mockResolvedValue(null),
getInstrumentByUid: jest.fn().mockResolvedValue(null), getInstrumentByUid: jest.fn().mockResolvedValue(null),
getOpenAccounts: jest.fn().mockResolvedValue([
{ id: 'a', name: 'A', type: 'ACCOUNT_TYPE_TINKOFF' },
{ id: 'b', name: 'B', type: 'ACCOUNT_TYPE_TINKOFF' },
]),
}; };
const token = new JwtService().sign( const token = new JwtService().sign(
{ sub: 'structure-user', type: 'access' }, { sub: 'structure-user', type: 'access' },
@@ -32,11 +41,16 @@ describe('Portfolio structure HTTP contract', () => {
const module = await Test.createTestingModule({ imports: [AppModule] }) const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient) .overrideProvider(TbankClient)
.useValue(tbank) .useValue(tbank)
.overrideProvider(TbankHourlyScheduler)
.useValue({ connected: jest.fn().mockResolvedValue(undefined) })
.compile(); .compile();
app = module.createNestApplication(); app = module.createNestApplication();
configureApp(app); configureApp(app);
await app.init(); await app.init();
await app.get(PrismaService).user.create({ prisma = app.get(PrismaService);
membership = app.get(TbankMembershipService);
portfolios = app.get(PortfolioService);
await prisma.user.create({
data: { data: {
id: 'structure-user', id: 'structure-user',
email: 'structure@example.invalid', email: 'structure@example.invalid',
@@ -65,21 +79,25 @@ describe('Portfolio structure HTTP contract', () => {
}); });
afterAll(() => app.close()); afterAll(() => app.close());
async function load(id: string) { async function load(id: string) {
await request(app.getHttpServer()) const connection = await prisma.tbankConnection.findUniqueOrThrow({
.get(`/api/v1/brokerage-accounts/${id}/portfolio`) where: { userId: 'structure-user' },
.set('Authorization', `Bearer ${token}`) });
.expect(200); const member = await membership.request(
for (let index = 0; index < 200; index += 1) { 'structure-user',
if ( connection.revision,
!(await app true,
.get(PrismaService) );
.tbankSyncWork.count({ where: { state: 'running' } })) if (member) await member.done;
) { const accountIds = id === 'all' ? ['a', 'b'] : [id];
return; for (const accountId of accountIds) {
} const work = await portfolios.request(
await delay(5); 'structure-user',
connection.revision,
accountId,
true,
);
if (work) await work.done;
} }
throw new Error('Portfolio did not settle');
} }
it.each(['a', 'all'])( it.each(['a', 'all'])(

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