2 Commits
Author SHA1 Message Date
ksv741 3bc5f49866 test: align upcoming events with local reads
Backend verify / commits (pull_request) Successful in 37s
Backend verify / security (pull_request) Successful in 18s
Backend verify / verify (pull_request) Failing after 2m5s
2026-10-09 20:41:38 +03:00
ksv741 aee330c6c2 feat: reconcile hourly investment resource states 2026-10-09 19:56:14 +03:00
220 changed files with 6057 additions and 10738 deletions
+6 -6
View File
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records; domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
чистые расчёты portfolio/banking остаются у domain owner. чистые расчёты portfolio/banking остаются у domain owner.
PrismaService владеет DB lifecycle. Application services/use cases получают PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
данные через concrete repositories; запросы и CAS живут в infrastructure через concrete repositories; существующие связные CRUD/transaction services
по [data convention](conventions/data.md). сохраняют прямые queries по [data convention](conventions/data.md).
Money — currency + decimal string; расчёты используют точные общие helpers. Money — currency + decimal string; расчёты используют точные общие helpers.
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
конкретные infrastructure providers, зарегистрированные module. Infrastructure конкретные infrastructure providers, зарегистрированные module. Infrastructure
может использовать application types/input и domain rules; новые interfaces может использовать application types/input и domain rules; новые interfaces
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
infrastructure. Application не строит Prisma queries: use case и связный CRUD infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface по [Data](conventions/data.md); use case получает данные через repository и сам
на каждую таблицу не требуется. Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
DTO остаются транспортными runtime classes; существующее использование DTO DTO остаются транспортными runtime classes; существующее использование DTO
на application boundary допустимо по [HTTP API](conventions/http-api.md). на application boundary допустимо по [HTTP API](conventions/http-api.md).
+10 -10
View File
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
не становятся domain entities: расчёт получает только нужные ему значения. не становятся domain entities: расчёт получает только нужные ему значения.
Application-код не импортирует PrismaService и не строит Prisma queries. Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
Репозиторий владеет запросом и CAS-проверкой результата, а application — services (auth, lifecycle подключения, изменения карт/операций/категорий)
правилами и формированием ответа. Типы Prisma допустимы только в type-only сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
импортах для существующей транзакционной границы. Не создавай обёртку лишь набора pass-through методов. Новая самостоятельная read/write capability
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write` получает repository method; самостоятельный use case не выполняет Prisma queries.
сохраняет retry/transaction boundary для банковских записей; callback с `BankCardsRepository.write` сохраняет общую retry/transaction boundary для
transaction client — локальная техническая граница, не API для domain. существующих banking services. Такой callback с Prisma transaction client —
локальная техническая граница этих services, не API для domain или use cases.
Выбирай только нужные данные, сохраняй user/account ownership в запросах и Выбирай только нужные данные, сохраняй user/account ownership в запросах и
application checks. Предварительный lookup не заменяет unique constraint при race. application checks. Предварительный lookup не заменяет unique constraint при race.
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
+13 -35
View File
@@ -7,19 +7,11 @@
checkout. Внешний API задаётся предоставленным публичным контрактом; checkout. Внешний API задаётся предоставленным публичным контрактом;
исходники или инструкции его реализации не являются зависимостью разработки. исходники или инструкции его реализации не являются зависимостью разработки.
При нехватке требования сообщи, какое решение не определено, до его реализации. При нехватке требования сообщи, какое решение не определено, до его реализации.
При конфликте с общим шаблоном следуй местным правилам и явному запросу
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
логики, публичного контракта или утверждённой семантики возвращай владельцу
на согласование до зависимой работы.
Начни с результата и affected areas. До edits проследи flow/invariants, найди Начни с результата и affected areas. До edits проследи flow/invariants, найди
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт. owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
начинай после явного решения Change Owner. Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
Перед 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
@@ -65,33 +57,19 @@ Peer-конфликт решай совместимыми версиями бе
## Проверки и завершение ## Проверки и завершение
| Когда | Что проверить | Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
| --- | --- | risk checks из местной testing convention. Targeted tests ускоряют итерацию,
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. | но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. | Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. | чтения и `git diff --check`; application build ради текста не нужен.
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. | Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
и ограничения. Зелёный 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 и релизы
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`, Работа — в `codex/<task>` от актуального `develop`, затем PR в `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 с parser-recognized Issue reference; допустим Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила содержит одну связанную задачу. Root другого checkout не управляет этим repo.
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
обычную работу. PR сливается squash или rebase-merge; merge-коммит не обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
используется. `main` обновляется только fast-forward из `develop`, поэтому
`origin/main` всегда предок `origin/develop`:
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
Перед релизом прочитай местный README, раздел «Релизы».
-12
View File
@@ -1,12 +0,0 @@
---
name: Bug Report
about: Неверное поведение
---
## Проблема и шаги воспроизведения
## Факт / ожидание
## Acceptance Criteria
- [ ]
## Окружение / ссылки (если применимо)
-10
View File
@@ -1,10 +0,0 @@
---
name: Engineering Task
about: CI, тесты, документация, процесс и прочее
---
## Контекст и результат
## Scope / связанные Issues (если есть)
## Acceptance Criteria
- [ ]
-12
View File
@@ -1,12 +0,0 @@
---
name: Feature Request
about: Новое наблюдаемое поведение
---
## Зачем и какой результат
## Ожидаемое поведение
## Acceptance Criteria
- [ ]
## Зависимости / связанные Issues (если есть)
-12
View File
@@ -1,12 +0,0 @@
---
name: Refactoring / Technical Debt
about: Улучшение без нового поведения
---
## Проблема и результат
## Сохраняемые инварианты / границы
## Acceptance Criteria
- [ ]
## Зависимости / ссылки (если есть)
-11
View File
@@ -1,11 +0,0 @@
## Summary
## Linked Issue
Refs
## Changes
## Verification
- Команда / результат / ограничения:
## Breaking Changes (если есть)
+2 -1
View File
@@ -28,8 +28,9 @@ jobs:
docker version docker version
docker buildx version docker buildx version
- run: npm ci - run: npm ci
- run: npx --no-install prisma generate
- run: npm run verify - run: npm run verify
- run: node test/sqlite.cjs --all - run: npm run test:e2e
- name: Login with dedicated Nexus writer - name: Login with dedicated Nexus writer
env: env:
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }} REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
+5 -9
View File
@@ -34,17 +34,12 @@ 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
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" npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
fi
security: security:
needs: commits needs: commits
if: >- if: >-
!cancelled() && !cancelled() &&
gitea.event.action != 'edited' &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped') (needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15 timeout-minutes: 15
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -67,13 +62,12 @@ jobs:
- name: Audit runtime and development dependencies - name: Audit runtime and development dependencies
run: | run: |
npm audit --omit=dev --audit-level=low npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
verify: verify:
needs: [commits, security] needs: [commits, security]
if: >- if: >-
!cancelled() && gitea.event.action != 'edited' && !cancelled() && needs.security.result == 'success' &&
needs.security.result == 'success' &&
gitea.event_name != 'schedule' gitea.event_name != 'schedule'
timeout-minutes: 45 timeout-minutes: 45
runs-on: ubuntu-latest runs-on: ubuntu-latest
@@ -87,10 +81,12 @@ jobs:
with: with:
node-version-file: .nvmrc node-version-file: .nvmrc
- run: npm ci - run: npm ci
- name: Generate Prisma client before typechecks
run: npx --no-install prisma generate
- name: Run all backend gates - name: Run all backend gates
run: npm run verify run: npm run verify
- name: Run isolated SQLite and runtime OpenAPI suites - name: Run isolated SQLite and runtime OpenAPI suites
run: node test/sqlite.cjs --all run: npm run test:e2e
- name: Analyze develop with SonarQube - name: Analyze develop with SonarQube
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop' if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
# v2.3.0: same Docker-based scanner used for SonarQube 9.9. # v2.3.0: same Docker-based scanner used for SonarQube 9.9.
+1 -1
View File
@@ -1 +1 @@
24.22.1 24.21.0
-3
View File
@@ -4,9 +4,6 @@
только его файлы и предоставленные публичные контракты. Перед работой прочитай только его файлы и предоставленные публичные контракты. Перед работой прочитай
[порядок работы](.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`).
| Решение / сигнал | Читать | | Решение / сигнал | Читать |
| --- | --- | | --- | --- |
-165
View File
@@ -1,170 +1,5 @@
# Changelog # Changelog
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
### Bug Fixes
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
### Refactoring
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
### CI
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
### Maintenance
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
### Features
* extend account list portfolio summary ([#138](https://git.personal.ksv741.ru/WealthLedger/backend/issues/138)) ([97fc41f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97fc41fa295a7a943a27171fa41cdaf286632956))
* **portfolio:** store and publish instrument sector and issuer (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/backend/issues/48)) ([#148](https://git.personal.ksv741.ru/WealthLedger/backend/issues/148)) ([17432df](https://git.personal.ksv741.ru/WealthLedger/backend/commit/17432dfee3d7d06841caea4419a062e2a4bfc82c))
### Bug Fixes
* count brokerage cash once and skip short-position return percent (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([f2843e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f2843e30bda5442158ec145cc63a5491506bb60f))
* reset portfolio value points inflated by double-counted cash (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([2325b63](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd))
* scope income and ledger sync to requested intervals (WealthLedger/backend[#57](https://git.personal.ksv741.ru/WealthLedger/backend/issues/57)) ([#147](https://git.personal.ksv741.ru/WealthLedger/backend/issues/147)) ([5e84e1e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5e84e1e5f1943b5af1992a3a946194b27fc1a1d5))
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
### Refactoring
* expose tbank-sync public api and guard its boundary (WealthLedger/backend[#118](https://git.personal.ksv741.ru/WealthLedger/backend/issues/118)) ([#143](https://git.personal.ksv741.ru/WealthLedger/backend/issues/143)) ([5295945](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5295945ab237a6e94aa64d7bb54bfd04554ca9f9))
* publish typed problem detail codes (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/backend/issues/117)) ([#144](https://git.personal.ksv741.ru/WealthLedger/backend/issues/144)) ([d37fa2c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d37fa2c9034e90e9d22d4022c45fc36c22dd2ab3))
* remove tbank-sync operations import cycle (WealthLedger/backend[#116](https://git.personal.ksv741.ru/WealthLedger/backend/issues/116)) ([#142](https://git.personal.ksv741.ru/WealthLedger/backend/issues/142)) ([22668da](https://git.personal.ksv741.ru/WealthLedger/backend/commit/22668da3fa0666bdd22737913379c99d4cf01993))
### Tests
* cover portfolio publish rollback and claim fence (WealthLedger/backend[#105](https://git.personal.ksv741.ru/WealthLedger/backend/issues/105)) ([#146](https://git.personal.ksv741.ru/WealthLedger/backend/issues/146)) ([e350146](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e350146749293f2eaf8060f7626ff3c1cd8edf2c))
* replace operations sync delays with explicit barriers (WealthLedger/backend[#104](https://git.personal.ksv741.ru/WealthLedger/backend/issues/104)) ([#145](https://git.personal.ksv741.ru/WealthLedger/backend/issues/145)) ([99b137e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/99b137ed0b39ab73b6a28d1a494d2d98bf8fa380))
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
### Bug Fixes
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
### Features
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
### Bug Fixes
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
### Maintenance
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
### Features
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
### Bug Fixes
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
### Performance
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
### Refactoring
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
### CI
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
### Tests
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
### Maintenance
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
* add hourly portfolio sync history ([0057925](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0057925888dde874830149865fc5e13920ac5783))
* add stock screener ([7d10ef8](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7d10ef8adfce76ad2ed6368e1022fbd3f6847a23))
* reconcile hourly investment resource states ([323b83b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/323b83b5827d1191fbc14cfdf1d0e62f249fc78d))
### Bug Fixes
* align backend workflow rules (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c7d0600](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c7d060023b612af9e1dd4c2a2fb2d5d1bf173784))
* bound hourly step awaits by the per-step wait limit ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([52fa3a3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/52fa3a36baa5ab236a7ebd64ef1b4f447c91e129))
* keep candle retries read-only ([947ffef](https://git.personal.ksv741.ru/WealthLedger/backend/commit/947ffefa9b8cad7f19355c187f2bd50c9739a822))
* restore terminal hourly lifecycle after persistence failure ([#56](https://git.personal.ksv741.ru/WealthLedger/backend/issues/56)) ([fa57a20](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa57a201fdd8127774aee9af553da75a75add07c))
* unblock backend dependency audit ([85858a1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85858a160f00dd428c80b8fa1824c1779245fdfe))
### Documentation
* clarify backend verification policy ([d7895f2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d7895f287ee802e7b13df7a2a4c6dba0b711e5a9))
* pin backend workflow reference (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([c5248db](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5248dbefc4b11a9f417bc835041a4294bd61047))
### CI
* skip checks on PR metadata edits ([0ad84ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0ad84ff4f0b4712abc7e69dc299856f14af6eadd))
### Tests
* align all accounts metadata ([db375cb](https://git.personal.ksv741.ru/WealthLedger/backend/commit/db375cbee7d8f1eff48822b8e3c74898f40e8886))
* align resource availability metadata ([bc631e7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bc631e72de0d9b0ff7b261b1ea644a73c2943d2a))
* align upcoming events with local reads ([966b2a5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966b2a5836cdaf5b761e3c76568ff30a90ba1f4c))
* extend ledger sqlite timeout ([4870d49](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4870d4950303ad1de743e19b665b2fbdc05b781a))
* increase ledger request timeout ([2facd86](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2facd8675c74a97d92e0e8f44ed4a93aabb47674))
### Maintenance
* align backend engineering workflow (WealthLedger/main[#27](https://git.personal.ksv741.ru/WealthLedger/backend/issues/27)) ([a31e8d2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a31e8d2c4d85a21cdd5efdcc9f5fe503f210804b))
* prune obsolete eslint suppressions ([a46a411](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a46a4112cbcc72b1c3ff17cac4ac8bebcf579bca))
## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07) ## [2.0.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v1.0.0...v2.0.0) (2026-10-07)
### ⚠ BREAKING CHANGES ### ⚠ BREAKING CHANGES
+5 -6
View File
@@ -1,9 +1,9 @@
FROM node:24.22.1-bookworm-slim AS base FROM node:24.21.0-bookworm-slim AS base
WORKDIR /app WORKDIR /app
FROM base AS dependencies FROM base AS dependencies
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime. # Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates openssl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/*
COPY package.json package-lock.json ./ COPY package.json package-lock.json ./
RUN npm ci RUN npm ci
COPY . . COPY . .
@@ -11,7 +11,7 @@ RUN npx --no-install prisma generate
FROM dependencies AS development FROM dependencies AS development
# Nest watch uses ps to stop the previous process tree before restarting. # Nest watch uses ps to stop the previous process tree before restarting.
RUN apt-get update && apt-get install -y --no-install-recommends procps && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*
RUN mkdir /data && chown node:node /data && chown -R node:node /app RUN mkdir /data && chown node:node /data && chown -R node:node /app
USER node USER node
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
@@ -19,11 +19,10 @@ EXPOSE 3000
CMD ["npm", "run", "start:dev"] CMD ["npm", "run", "start:dev"]
FROM dependencies AS build FROM dependencies AS build
# The client is already generated at the shared dependencies stage (same revision). RUN npm run build && npm prune --omit=dev --omit=optional
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
FROM base AS production FROM base AS production
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl && rm -rf /var/lib/apt/lists/* \ RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
&& mkdir /data && chown node:node /data && mkdir /data && chown node:node /data
ARG VERSION ARG VERSION
ARG REVISION ARG REVISION
+24 -58
View File
@@ -14,7 +14,6 @@ cp .env.example .env
nvm use --silent nvm use --silent
npm ci npm ci
npx prisma migrate deploy npx prisma migrate deploy
npx prisma generate
npm run start:dev npm run start:dev
``` ```
@@ -61,7 +60,7 @@ Compose topology принадлежит workspace, эксплуатационн
npm run verify npm run verify
``` ```
Он запускает генерацию Prisma client, 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
@@ -81,13 +80,11 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify` Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
последовательно на PR; на push в `develop`/`main` — `security → verify`. последовательно на PR; на push в `develop`/`main` — `security → verify`.
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска. Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
Изменение заголовка PR повторяет только проверку Conventional Commits; `security` Изменение заголовка PR тоже повторяет проверки Conventional Commits.
и `verify` на metadata-only событии не запускаются.
Новый запуск отменяет устаревший для того же PR/ветки и типа события Новый запуск отменяет устаревший для того же PR/ветки и типа события
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`. (требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client Job `verify` запускает `npm ci`, генерацию Prisma client,
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all` `npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
на уже собранных артефактах. Используется только checkout
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог. backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL. Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
@@ -218,10 +215,7 @@ nvm use --silent
npm run openapi:generate npm run openapi:generate
``` ```
Команда собирает backend и записывает `openapi.json` в корень репозитория Команда собирает backend и записывает `openapi.json` в корень репозитория.
атомарно: через временный файл и rename, без частичной записи при сбое.
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
с ошибкой при расхождении, не перезаписывая его.
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
@@ -254,14 +248,10 @@ breaking changes относительно предыдущего выпуска.
настраивается отдельно. История migrations применяется штатным `migrate deploy`. настраивается отдельно. История migrations применяется штатным `migrate deploy`.
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
три последовательных и два параллельных запуска двух обычных e2e suites, три последовательных и два параллельных полных e2e, проверяет уникальность DB,
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
Логи остаются в каталоге из вывода. Для одной suite: Логи остаются в каталоге из вывода. Для одной suite:
`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в `node test/sqlite.cjs app.e2e-spec.ts`.
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
расходиться с parent/src.
| Риск | Проверка реальной DB | | Риск | Проверка реальной DB |
| --- | --- | | --- | --- |
@@ -305,59 +295,35 @@ Durability оставляет временные DB, логи и `result.json`
## Релизы ## Релизы
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`; релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
общие правила — в Engineering Workflow по ссылке из AGENTS. Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
PR сливается squash или rebase-merge; merge-коммит не используется. `main` Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
обновляется только fast-forward из `develop`, поэтому история линейна:
```text 1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z `develop` содержит последний релиз. Создай `release/<name>` от проверенного
``` `develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
`release/hotfix-<name>` и проходит тот же процесс.
Инвариант: `origin/main` всегда предок `origin/develop`. Проверка:
```bash
git fetch -q origin
git merge-base --is-ancestor origin/main origin/develop && echo ok || echo BROKEN
```
Commits и PR titles — Conventional Commits со ссылкой на Issue (кроме release),
включая `spec:`. Версии вручную не повышай.
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Проверь
инвариант. Создай `release/X.Y.Z` от проверенного `develop`;
`nvm use --silent`, `npm ci`.
2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog, 2. На чистой ветке выполни `npm run release:preview`, просмотри версию/changelog,
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`, затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии. guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку, 3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
`develop` изменился, обнови release-ветку и повтори проверки кандидата. изменился, согласуй историю и повтори проверки кандидата.
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only. 4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA: manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только `git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`. `git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3. 5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
Опубликованные теги не перемещай. При сбое после merge создай отсутствующий тег что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
без нового bump; до merge исправь и проверь существующий кандидат без merge создай отсутствующий тег без нового bump; до merge исправь и проверь
повторного повышения версии. существующий кандидат без повторного повышения версии.
Freeze релиза не поддерживается: пока открыт релизный PR, `develop` не двигают.
Срочный fix — обычная ветка `fix/*` от `develop` с последующим релизом;
пути от `main` нет, он сделал бы `main` не-предком `develop`. Исключение
требует явного решения: `release/hotfix-<name>` от `main`, PR в `main`, тег,
затем merge `main → develop` для восстановления инварианта.
`feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch; `feat:` повышает minor, `fix:`/`perf:` и прочие разрешённые типы — patch;
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):` `!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`. исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
администратором, включена блокировка устаревшей ветки, обязательны `verify`, администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
Workflow сам не включает эти настройки. Отдельное review approval для одного Workflow сам не включает эти настройки. Отдельное review approval для одного
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены. владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
+1 -9
View File
@@ -1,12 +1,4 @@
module.exports = { module.exports = {
extends: ['@commitlint/config-conventional'], extends: ['@commitlint/config-conventional'],
defaultIgnores: false, rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
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'],
},
}; };
+39 -2
View File
@@ -32,6 +32,11 @@
"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
@@ -53,6 +58,14 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
@@ -86,17 +99,25 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/investment-sync-plan.ts": { "src/modules/investments/investment-resource.mapper.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 3 "count": 3
} }
}, },
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": { "src/modules/investments/operations/application/brokerage-operations.service.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 2 "count": 2
}, },
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 6 "count": 9
} }
}, },
"src/modules/investments/operations/domain/operations-sync-plan.ts": { "src/modules/investments/operations/domain/operations-sync-plan.ts": {
@@ -125,11 +146,24 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/portfolio/application/portfolio.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/tbank-sync/tbank-screen.ts": { "src/modules/investments/tbank-sync/tbank-screen.ts": {
"sonarjs/cognitive-complexity": { "sonarjs/cognitive-complexity": {
"count": 1 "count": 1
} }
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -148,6 +182,9 @@
"test/tbank-ledger-local.e2e-spec.ts": { "test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
} }
} }
} }
+3 -33
View File
@@ -15,18 +15,6 @@ import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts']; const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const guardedModules = [
{
name: 'tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
},
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
];
const prismaImports = [ const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service', '@prisma/*', '**/generated/prisma/**', '**/prisma.service',
]; ];
@@ -49,7 +37,7 @@ export default defineConfig(
linterOptions: { reportUnusedDisableDirectives: 'error' }, linterOptions: { reportUnusedDisableDirectives: 'error' },
settings: { settings: {
'import-x/resolver-next': [createTypeScriptImportResolver()], 'import-x/resolver-next': [createTypeScriptImportResolver()],
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' }, 'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
}, },
rules: { rules: {
'curly': ['error', 'multi-line'], 'curly': ['error', 'multi-line'],
@@ -68,7 +56,6 @@ export default defineConfig(
'after', 'after',
{ overrides: { '?': 'before', ':': 'before' } }, { overrides: { '?': 'before', ':': 'before' } },
], ],
'import-x/no-cycle': 'error',
'import-x/no-duplicates': ['error', { 'prefer-inline': true }], 'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
}, },
}, },
@@ -170,14 +157,13 @@ export default defineConfig(
}, },
}, },
{ {
files: ['src/**/application/**/*.ts'], files: ['src/**/*.use-case.ts'],
ignores: tests, ignores: tests,
rules: { rules: {
'no-restricted-imports': ['error', { 'no-restricted-imports': ['error', {
patterns: [{ patterns: [{
group: prismaImports, group: prismaImports,
allowTypeImports: true, message: 'Use cases obtain data through concrete repositories.',
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
}], }],
}], }],
}, },
@@ -225,20 +211,4 @@ export default defineConfig(
'@typescript-eslint/no-unsafe-enum-comparison': 'error', '@typescript-eslint/no-unsafe-enum-comparison': 'error',
}, },
}, },
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
// A module may import its own internals, so each guarded module gets a block without its zone.
...[undefined, ...guardedModules].map((own) => ({
files: own ? [`src/modules/investments/${own.name}/**/*.ts`] : ['src/**/*.ts'],
ignores: own ? tests : [...tests, ...guardedModules.map(({ name }) => `src/modules/investments/${name}/**`)],
rules: {
'import-x/no-restricted-paths': ['error', {
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
target: './src',
from: `./src/modules/investments/${name}`,
except,
message: `Use ${name}.api for cross-module access.`,
})),
}],
},
})),
); );
+1292 -1021
View File
File diff suppressed because it is too large Load Diff
+46 -69
View File
@@ -1,31 +1,20 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.4.1", "version": "2.0.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"imports": {
"#generated/*": {
"development": "./dist-dev/generated/*",
"default": "./dist/generated/*"
},
"#src/*": {
"development": "./dist-dev/src/*",
"default": "./dist/src/*"
}
},
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": "v24.22.1" "node": "v24.21.0"
}, },
"scripts": { "scripts": {
"build": "prisma generate && nest build", "build": "prisma generate && nest build",
"openapi:generate": "npm run build && node scripts/generate-openapi.cjs", "openapi:generate": "npm run build && node scripts/generate-openapi.cjs",
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
"format": "npm run lint:fix", "format": "npm run lint:fix",
"start": "nest start", "start": "nest start",
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
"start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json", "start:debug": "nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint . --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
@@ -42,109 +31,96 @@
"test:container": "node test/container-smoke.cjs", "test:container": "node test/container-smoke.cjs",
"test:durability": "node test/bank-cards-durability.cjs", "test:durability": "node test/bank-cards-durability.cjs",
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts", "test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
"verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build", "verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
"release:preview": "release-it --dry-run --ci", "release:preview": "release-it --dry-run --ci",
"release:prepare": "release-it --ci", "release:prepare": "release-it --ci",
"commitlint": "commitlint", "commitlint": "commitlint",
"test:lint": "node --test test/eslint-config.cjs" "test:lint": "node --test test/eslint-config.cjs"
}, },
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.6", "@grpc/grpc-js": "1.14.5",
"@grpc/proto-loader": "0.8.1", "@grpc/proto-loader": "0.8.1",
"@nestjs/common": "12.2.0", "@nestjs/common": "12.0.1",
"@nestjs/config": "12.0.1", "@nestjs/config": "12.0.0",
"@nestjs/core": "12.2.0", "@nestjs/core": "12.0.1",
"@nestjs/jwt": "12.0.3", "@nestjs/jwt": "12.0.1",
"@nestjs/passport": "12.0.0", "@nestjs/passport": "12.0.0",
"@nestjs/platform-express": "12.2.0", "@nestjs/platform-express": "12.0.1",
"@nestjs/swagger": "12.0.4", "@nestjs/swagger": "12.0.1",
"@prisma/adapter-better-sqlite3": "7.10.1", "@prisma/adapter-better-sqlite3": "7.10.0",
"@prisma/client": "7.10.1", "@prisma/client": "7.10.0",
"bcrypt": "6.0.0", "bcrypt": "6.0.0",
"better-sqlite3": "12.11.1", "better-sqlite3": "13.0.3",
"cheerio": "1.2.0", "cheerio": "1.2.0",
"class-transformer": "0.5.1", "class-transformer": "0.5.1",
"class-validator": "0.15.1", "class-validator": "0.15.1",
"cookie-parser": "1.4.7", "cookie-parser": "1.4.7",
"dotenv": "17.4.2", "dotenv": "17.4.2",
"lru-cache": "11.5.3", "lru-cache": "11.5.2",
"nestjs-pino": "5.3.1", "nestjs-pino": "5.1.0",
"passport": "0.7.0", "passport": "0.7.0",
"passport-jwt": "4.0.1", "passport-jwt": "4.0.1",
"pino-http": "11.0.0", "pino-http": "11.0.0",
"prisma": "7.10.1", "prisma": "7.10.0",
"reflect-metadata": "0.2.2", "reflect-metadata": "0.2.2",
"rxjs": "7.8.2" "rxjs": "7.8.2"
}, },
"devDependencies": { "devDependencies": {
"@commitlint/cli": "21.2.4", "@commitlint/cli": "21.2.2",
"@commitlint/config-conventional": "21.2.4", "@commitlint/config-conventional": "21.2.2",
"@darraghor/eslint-plugin-nestjs-typed": "7.5.8", "@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@nestjs/cli": "12.0.10", "@nestjs/cli": "12.0.0",
"@nestjs/schematics": "12.0.10", "@nestjs/schematics": "12.0.1",
"@nestjs/testing": "12.2.0", "@nestjs/testing": "12.0.1",
"@release-it/conventional-changelog": "12.0.2", "@release-it/conventional-changelog": "12.0.0",
"@stylistic/eslint-plugin": "5.10.0", "@stylistic/eslint-plugin": "5.10.0",
"@types/bcrypt": "6.0.0", "@types/bcrypt": "6.0.0",
"@types/cookie-parser": "1.4.10", "@types/cookie-parser": "1.4.10",
"@types/express": "5.0.6", "@types/express": "5.0.6",
"@types/jest": "30.0.0", "@types/jest": "30.0.0",
"@types/node": "24.19.2", "@types/node": "24.13.4",
"@types/passport-jwt": "4.0.1", "@types/passport-jwt": "4.0.1",
"@types/supertest": "7.2.1", "@types/supertest": "7.2.1",
"conventional-changelog-conventionalcommits": "10.4.1", "conventional-changelog-conventionalcommits": "10.4.0",
"eslint": "10.13.0", "eslint": "10.11.0",
"eslint-import-resolver-typescript": "4.4.5", "eslint-import-resolver-typescript": "4.4.5",
"eslint-plugin-import-x": "4.17.1", "eslint-plugin-import-x": "4.17.1",
"eslint-plugin-jest": "29.17.0", "eslint-plugin-jest": "29.16.6",
"eslint-plugin-n": "18.5.0", "eslint-plugin-n": "18.4.0",
"eslint-plugin-perfectionist": "5.13.0", "eslint-plugin-perfectionist": "5.12.1",
"eslint-plugin-regexp": "3.3.1", "eslint-plugin-regexp": "3.3.1",
"eslint-plugin-security": "4.2.0", "eslint-plugin-security": "4.1.0",
"eslint-plugin-sonarjs": "4.2.2", "eslint-plugin-sonarjs": "4.2.1",
"globals": "17.13.0", "globals": "17.12.0",
"jest": "30.5.2", "jest": "30.5.1",
"pino-pretty": "13.2.0", "release-it": "21.0.2",
"release-it": "21.1.1", "source-map-support": "0.5.21",
"supertest": "7.3.1", "supertest": "7.2.2",
"ts-jest": "29.4.15", "ts-jest": "29.4.12",
"ts-loader": "9.6.2", "ts-loader": "9.6.2",
"ts-node": "10.9.2", "ts-node": "10.9.2",
"tsconfig-paths": "4.2.0", "tsconfig-paths": "4.2.0",
"typescript": "6.0.3", "typescript": "6.0.3",
"typescript-eslint": "8.72.0" "typescript-eslint": "8.70.1"
}, },
"overrides": { "overrides": {
"proxy-addr": "2.0.8", "proxy-addr": "2.0.8",
"express": {
"body-parser": "2.4.0"
},
"@nestjs/platform-express": { "@nestjs/platform-express": {
"multer": "2.5.0" "multer": "2.4.0"
},
"ajv": {
"fast-uri": "3.1.9"
}, },
"@prisma/config@7.10.0": { "@prisma/config@7.10.0": {
"deepmerge-ts": "8.0.2" "deepmerge-ts": "8.0.2"
}, },
"release-it": { "release-it": {
"basic-ftp": "6.2.3", "basic-ftp": "6.2.1",
"undici": "7.30.1" "undici": "7.29.1"
},
"socks": {
"ip-address": "10.7.4"
}, },
"prisma": { "prisma": {
"mysql2": "3.24.6" "mysql2": "3.24.4"
} }
}, },
"jest": { "jest": {
"moduleNameMapper": {
"^#generated/(.*)$": "<rootDir>/../generated/$1",
"^#src/(.*)$": "<rootDir>/$1"
},
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
"json", "json",
@@ -168,6 +144,7 @@
"better-sqlite3@12.11.1": true, "better-sqlite3@12.11.1": true,
"fsevents@2.3.3": true, "fsevents@2.3.3": true,
"protobufjs@7.6.5": true, "protobufjs@7.6.5": true,
"unrs-resolver@1.12.2": true "unrs-resolver@1.12.2": true,
"better-sqlite3@13.0.3": true
} }
} }
@@ -1,4 +0,0 @@
-- Points written before WealthLedger/backend#140 counted cash twice (cash balance
-- plus the mirroring CURRENCY position). Historical compositions and FX rates are
-- not stored, so the points cannot be recomputed exactly; history restarts here.
DELETE FROM "TbankPortfolioValuePoint";
@@ -1,11 +0,0 @@
CREATE TABLE "TbankInstrumentClassification" (
"provider" TEXT NOT NULL,
"instrumentKey" TEXT NOT NULL,
"sector" TEXT,
"sectorFetchedAt" DATETIME,
"assetUid" TEXT,
"brandUid" TEXT,
"brandName" TEXT,
"brandFetchedAt" DATETIME,
PRIMARY KEY ("provider","instrumentKey")
);
@@ -1,15 +0,0 @@
CREATE TABLE "MarketIndexDailyClose" (
"secid" TEXT NOT NULL,
"tradeDate" TEXT NOT NULL,
"close" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate")
);
CREATE TABLE "MarketIndexSyncState" (
"secid" TEXT NOT NULL PRIMARY KEY,
"lastAttemptAt" DATETIME,
"lastSuccessAt" DATETIME,
"coveredFrom" TEXT,
"coveredTo" TEXT
);
@@ -1,32 +0,0 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_TbankPortfolioValuePoint" (
"userId" TEXT NOT NULL,
"accountId" TEXT NOT NULL,
"attemptId" TEXT NOT NULL,
"value" TEXT NOT NULL,
"capturedAt" DATETIME NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("userId", "accountId", "attemptId"),
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
);
-- RUB-only: keep the RUB row per key; non-RUB rows are dropped.
INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value")
SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value"
FROM "TbankPortfolioValuePoint"
WHERE "currency" = 'RUB';
DROP TABLE "TbankPortfolioValuePoint";
ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint";
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
-- RUB-only: persisted JSON payloads written in the old multi-currency shape
-- are stale. A missing snapshot/forecast is the app's normal "not synced yet"
-- state: reads report availability 'missing' and the hourly walk rebuilds them
-- on the next pass. Delete the stale rows instead of failing reads with 500.
DELETE FROM "TbankPortfolio";
DELETE FROM "TbankForecast";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;
@@ -1,3 +0,0 @@
-- RUB-only: snapshots were dropped by the previous migration; clearing the hourly
-- schedule makes the next scheduler tick rebuild them instead of waiting for the term.
DELETE FROM "TbankSyncWork" WHERE "scopeKey" = 'hourly';
+3 -34
View File
@@ -8,24 +8,6 @@ datasource db {
provider = "sqlite" provider = "sqlite"
} }
// Public MOEX index closes contain no user holdings or credentials.
model MarketIndexDailyClose {
secid String
tradeDate String
close String
createdAt DateTime @default(now())
@@id([secid, tradeDate])
}
model MarketIndexSyncState {
secid String @id
lastAttemptAt DateTime?
lastSuccessAt DateTime?
coveredFrom String?
coveredTo String?
}
model User { model User {
id String @id id String @id
email String @unique email String @unique
@@ -101,17 +83,18 @@ model TbankPortfolioValueHistory {
@@id([userId, accountId]) @@id([userId, accountId])
} }
// One row per fully valued successful portfolio attempt (RUB only). // One row per currency of a fully valued successful portfolio attempt.
model TbankPortfolioValuePoint { model TbankPortfolioValuePoint {
userId String userId String
accountId String accountId String
attemptId String attemptId String
currency String
value String value String
capturedAt DateTime capturedAt DateTime
createdAt DateTime @default(now()) createdAt DateTime @default(now())
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict) history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
@@id([userId, accountId, attemptId]) @@id([userId, accountId, attemptId, currency])
@@index([userId, accountId, capturedAt]) @@index([userId, accountId, capturedAt])
} }
@@ -203,20 +186,6 @@ model TbankInstrumentLookup {
@@id([provider, identifierType, identifier]) @@id([provider, identifierType, identifier])
} }
// Public per-instrument classification; no holdings or credentials.
model TbankInstrumentClassification {
provider String
instrumentKey String
sector String?
sectorFetchedAt DateTime?
assetUid String?
brandUid String?
brandName String?
brandFetchedAt DateTime?
@@id([provider, instrumentKey])
}
// Source records contain no holdings, personal estimates or credentials errors. // Source records contain no holdings, personal estimates or credentials errors.
model TbankInstrumentSource { model TbankInstrumentSource {
provider String provider String
+1 -30
View File
@@ -2,8 +2,7 @@
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": [
"config:recommended", "config:recommended",
":semanticCommitTypeAll(chore)", ":semanticCommitTypeAll(chore)"
"security:minimumReleaseAgeNpm"
], ],
"timezone": "Europe/Moscow", "timezone": "Europe/Moscow",
"labels": [ "labels": [
@@ -11,13 +10,6 @@
], ],
"dependencyDashboard": true, "dependencyDashboard": true,
"rangeStrategy": "pin", "rangeStrategy": "pin",
"prConcurrentLimit": 5,
"lockFileMaintenance": {
"enabled": true,
"schedule": [
"before 4am on the first day of the month"
]
},
"packageRules": [ "packageRules": [
{ {
"matchUpdateTypes": [ "matchUpdateTypes": [
@@ -26,27 +18,6 @@
], ],
"groupName": "non-major dependencies" "groupName": "non-major dependencies"
}, },
{
"matchManagers": [
"github-actions"
],
"matchUpdateTypes": [
"minor",
"patch"
],
"groupName": "non-major github actions"
},
{
"matchManagers": [
"docker-compose",
"dockerfile"
],
"matchUpdateTypes": [
"minor",
"patch"
],
"groupName": "non-major container images"
},
{ {
"matchUpdateTypes": [ "matchUpdateTypes": [
"major" "major"
+8 -38
View File
@@ -1,10 +1,5 @@
const { randomBytes } = require('node:crypto'); const { randomBytes } = require('node:crypto');
const { readFile, rename, rm, writeFile } = require('node:fs/promises'); const { writeFile } = require('node:fs/promises');
const { resolve } = require('node:path');
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
const target = resolve(process.cwd(), 'openapi.json');
const checkOnly = process.argv.includes('--check');
// Schema generation needs no deployment configuration or real credentials. // Schema generation needs no deployment configuration or real credentials.
Object.assign(process.env, { Object.assign(process.env, {
@@ -24,48 +19,23 @@ const { NestFactory } = require('@nestjs/core');
const { AppModule } = require('../dist/src/app.module.js'); const { AppModule } = require('../dist/src/app.module.js');
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js'); const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
async function render() { async function generate() {
const app = await NestFactory.create(AppModule, { const app = await NestFactory.create(AppModule, {
preview: true, preview: true,
logger: false, logger: false,
}); });
try { try {
app.setGlobalPrefix(API_PREFIX); app.setGlobalPrefix(API_PREFIX);
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n'; await writeFile(
'openapi.json',
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
);
} finally { } finally {
await app.close(); await app.close();
} }
} }
// Read-only: compares the runtime document with the committed artifact and never writes. generate().catch((error) => {
async function check(content) {
const current = await readFile(target, 'utf8').catch(() => null);
if (current !== content) {
console.error(
'openapi.json is out of date with the runtime schema. Run `npm run openapi:generate`.',
);
process.exitCode = 1;
return;
}
console.log('openapi.json matches the runtime schema.');
}
// Atomic update: write a sibling temp file, then rename over the target, so a failed run never leaves partial output.
async function update(content) {
const temp = `${target}.${process.pid}.tmp`;
try {
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await writeFile(temp, content);
// eslint-disable-next-line security/detect-non-literal-fs-filename -- Operator-selected working directory and fixed artifact name; no request input.
await rename(temp, target);
} finally {
await rm(temp, { force: true });
}
}
render()
.then((content) => (checkOnly ? check(content) : update(content)))
.catch((error) => {
console.error(error); console.error(error);
process.exitCode = 1; process.exitCode = 1;
}); });
-2
View File
@@ -9,7 +9,6 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
import { IncomeModule } from './modules/investments/income/income.module'; import { IncomeModule } from './modules/investments/income/income.module';
import { InstrumentsModule } from './modules/investments/instruments/instruments.module'; import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
import { OperationsModule } from './modules/investments/operations/operations.module'; import { OperationsModule } from './modules/investments/operations/operations.module';
import { PerformanceModule } from './modules/investments/performance/performance.module';
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module'; import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module'; import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module'; import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
@@ -28,7 +27,6 @@ import { PrismaModule } from './prisma/prisma.module';
BankCardsModule, BankCardsModule,
BrokerageAccountsModule, BrokerageAccountsModule,
PortfolioModule, PortfolioModule,
PerformanceModule,
OperationsModule, OperationsModule,
IncomeModule, IncomeModule,
UpcomingEventsModule, UpcomingEventsModule,
+2 -55
View File
@@ -1,6 +1,6 @@
import { BadRequestException, ValidationPipe } from '@nestjs/common'; import { BadRequestException, ValidationPipe } from '@nestjs/common';
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto'; import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
import { import {
CreateBankCardDto, CreateBankCardDto,
UpdateBankCardDto, UpdateBankCardDto,
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
forbidUnknownValues: true, transform: true, whitelist: true }); forbidUnknownValues: true, transform: true, whitelist: true });
it('requires credentials and normalizes a valid email', async () => { it('requires credentials and normalizes a valid email', async () => {
const metadata = { type: 'body' as const, metatype: RegisterDto }; const metadata = { type: 'body' as const, metatype: CredentialsDto };
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf( await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@@ -29,59 +29,6 @@ describe('DTO validation', () => {
}); });
}); });
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
// bytes so accounts created under the older policy can still sign in.
describe('password policy', () => {
const register = { type: 'body' as const, metatype: RegisterDto };
const login = { type: 'body' as const, metatype: LoginDto };
const body = (password: string) => ({ email: 'user@example.com', password });
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
pipe.transform(value, metadata).then(
() => true,
(error: unknown) => {
if (!(error instanceof BadRequestException)) throw error;
return false;
},
);
it.each([
['7 chars', 'a'.repeat(7), false],
['8 chars', 'a'.repeat(8), true],
['72 chars', 'a'.repeat(72), true],
['73 chars', 'a'.repeat(73), false],
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
['Cyrillic, 8+ chars', 'пароль-пароль', false],
['emoji', 'passw😀rd', false],
['tab', 'pass\tword1', false],
['newline', 'password1\n', false],
['DEL', 'password\x7f1', false],
['non-string', 12345678, false],
])('register: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
});
it('register keeps raw whitespace untouched', async () => {
const password = ' spaced pass ';
await expect(pipe.transform(body(password), register)).resolves.toEqual(
body(password),
);
});
it.each([
['legacy Cyrillic password', 'пароль-пароль', true],
['short legacy password', 'short', true],
['72 bytes', 'a'.repeat(72), true],
['73 bytes', 'a'.repeat(73), false],
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
['empty', '', false],
['non-string', 12345678, false],
])('login: %s', async (_, password, accepted) => {
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
});
});
it('requires and validates nested money in a new card', async () => { it('requires and validates nested money in a new card', async () => {
const metadata = { type: 'body' as const, metatype: CreateBankCardDto }; const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
const card = { const card = {
@@ -1,29 +1,24 @@
import { ArgumentsHost, BadRequestException } from '@nestjs/common'; import { ArgumentsHost } from '@nestjs/common';
import { StructuredLogger } from '../logging/structured-logger.service';
import { HttpExceptionFilter } from './http-exception.filter'; import { HttpExceptionFilter } from './http-exception.filter';
describe('HttpExceptionFilter', () => { describe('HttpExceptionFilter', () => {
const setup = () => { it('does not expose internal error details', () => {
const status = jest.fn().mockReturnThis(); const status = jest.fn().mockReturnThis();
const type = jest.fn().mockReturnThis(); const type = jest.fn().mockReturnThis();
const json = jest.fn(); const json = jest.fn();
const locals: Record<string, unknown> = {}; const locals: Record<string, unknown> = {};
const logger = { event: jest.fn() }; const filter = new HttpExceptionFilter();
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
const host = { const host = {
switchToHttp: () => ({ switchToHttp: () => ({
getResponse: () => ({ status, type, json, locals }), getResponse: () => ({ status, type, json, locals }),
}), }),
} as unknown as ArgumentsHost; } as unknown as ArgumentsHost;
return { filter, host, status, type, json, locals, logger };
};
it('logs unhandled errors with name and stack frames but never the message', () => { filter.catch(
const { filter, host, status, type, json, locals, logger } = setup(); new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'); host,
);
filter.catch(error, host);
expect(status).toHaveBeenCalledWith(500); expect(status).toHaveBeenCalledWith(500);
expect(type).toHaveBeenCalledWith('application/problem+json'); expect(type).toHaveBeenCalledWith('application/problem+json');
@@ -32,29 +27,6 @@ describe('HttpExceptionFilter', () => {
code: 'INTERNAL_SERVER_ERROR', code: 'INTERNAL_SERVER_ERROR',
}); });
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR'); expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
expect(logger.event).toHaveBeenCalledTimes(1); expect(locals.errorStack).toBeUndefined();
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
expect(level).toBe('error');
expect(event).toBe('http.request.unhandled');
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
});
it('logs field pointers without values for validation errors', () => {
const { filter, host, status, logger, locals } = setup();
const exception = new BadRequestException({
message: [
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
],
});
filter.catch(exception, host);
expect(status).toHaveBeenCalledWith(400);
expect(locals.invalidFields).toBe('#/amount');
expect(logger.event).not.toHaveBeenCalled();
expect(JSON.stringify(locals)).not.toContain('-42');
}); });
}); });
@@ -10,12 +10,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { HttpErrorCode } from '../error-code'; import { HttpErrorCode } from '../error-code';
import { StructuredLogger } from '../logging/structured-logger.service';
@Catch() @Catch()
export class HttpExceptionFilter implements ExceptionFilter { export class HttpExceptionFilter implements ExceptionFilter {
constructor(private readonly logger: StructuredLogger) {}
catch(exception: unknown, host: ArgumentsHost): void { catch(exception: unknown, host: ArgumentsHost): void {
const context = host.switchToHttp(); const context = host.switchToHttp();
const response = context.getResponse<Response>(); const response = context.getResponse<Response>();
@@ -34,18 +31,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
: this.codeFor(status, payload); : this.codeFor(status, payload);
response.locals ??= {}; response.locals ??= {};
response.locals.publicErrorCode = code; response.locals.publicErrorCode = code;
if (isValidation) {
response.locals.invalidFields = [
...new Set(
this.validationErrors(
(payload as { message: ValidationError[] }).message,
).map((error) => error.pointer),
),
].join(',');
}
if (!(exception instanceof HttpException)) {
this.logUnhandled(exception, code);
}
response response
.status(status) .status(status)
.type('application/problem+json') .type('application/problem+json')
@@ -62,20 +47,6 @@ export class HttpExceptionFilter implements ExceptionFilter {
}); });
} }
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
private logUnhandled(exception: unknown, code: string): void {
const error = exception instanceof Error ? exception : undefined;
const frames = (error?.stack ?? '')
.split('\n')
.filter((line) => line.startsWith(' at '))
.join('\n');
this.logger.event('error', 'http.request.unhandled', {
code,
errorName: error?.name ?? typeof exception,
errorStack: frames,
});
}
private codeFor(status: number, payload: unknown): string { private codeFor(status: number, payload: unknown): string {
if (typeof payload === 'object' && payload !== null && 'code' in payload) { if (typeof payload === 'object' && payload !== null && 'code' in payload) {
const code = (payload as { code?: unknown }).code; const code = (payload as { code?: unknown }).code;
-5
View File
@@ -24,7 +24,6 @@ function completion(request: Request, response: Response, value: { responseTime:
status: response.statusCode, status: response.statusCode,
durationMs: Math.round(value.responseTime), durationMs: Math.round(value.responseTime),
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}), ...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
}; };
} }
@@ -32,10 +31,6 @@ export function loggingOptions(config: ConfigService<Environment, true>): Option
return { return {
level: config.getOrThrow('LOG_LEVEL'), level: config.getOrThrow('LOG_LEVEL'),
messageKey: 'message', messageKey: 'message',
// ponytail: development-only pretty output; production never loads pino-pretty.
...(process.env['NODE_ENV'] === 'development'
? { transport: { target: 'pino-pretty', options: { colorize: true, singleLine: true, messageKey: 'message' } } }
: {}),
genReqId: (_request, response) => { genReqId: (_request, response) => {
const id = randomUUID(); const id = randomUUID();
response.setHeader('X-Request-ID', id); response.setHeader('X-Request-ID', id);
@@ -14,7 +14,6 @@ describe('StructuredLogger', () => {
const logger = new StructuredLogger(pino as never); const logger = new StructuredLogger(pino as never);
logger.info('event', { logger.info('event', {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
password: 'secret', password: 'secret',
@@ -22,7 +21,6 @@ describe('StructuredLogger', () => {
expect(pino.info).toHaveBeenCalledWith( expect(pino.info).toHaveBeenCalledWith(
{ {
asOf: '2026-10-10T12:00:00.000Z',
userId: 'user', userId: 'user',
brokerageAccountId: 'account', brokerageAccountId: 'account',
}, },
@@ -5,30 +5,21 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn';
type LogContext = Record<string, boolean | number | string | undefined>; type LogContext = Record<string, boolean | number | string | undefined>;
const allowedFields = new Set([ const allowedFields = new Set([
'asOf',
'attempts', 'attempts',
'brokerageAccountId', 'brokerageAccountId',
'channelState',
'clientRequestId', 'clientRequestId',
'code', 'code',
'count', 'count',
'durationMs', 'durationMs',
'environment', 'environment',
'errorName',
'errorStack',
'found', 'found',
'grpcCode', 'grpcCode',
'headersReceived',
'invalidFields',
'method', 'method',
'networkMs',
'operation', 'operation',
'queueWaitMs',
'requestId', 'requestId',
'route', 'route',
'service', 'service',
'status', 'status',
'trackingId',
'userId', 'userId',
]); ]);
-122
View File
@@ -8,73 +8,6 @@ const emptyEvents = {
describe('MoexClient', () => { describe('MoexClient', () => {
afterEach(() => jest.restoreAllMocks()); afterEach(() => jest.restoreAllMocks());
it('loads all pages of index closes without dropping a page', async () => {
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]],
},
'history.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[start, 2, 1]],
},
}),
} as Response);
});
expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([
{ tradeDate: '2026-10-01', close: '5752.22' },
{ tradeDate: '2026-10-02', close: '5745.61' },
]);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it('rejects a later malformed page instead of returning partial index history', async () => {
jest.spyOn(global, 'fetch').mockImplementation((input) => {
const start = Number(new URL(input as string).searchParams.get('start'));
return Promise.resolve({
ok: true,
status: 200,
json: () => Promise.resolve({
'history': {
columns: ['TRADEDATE', 'CLOSE'],
data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]],
},
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] },
}),
} as Response);
});
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects an unsuccessful index history response', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response);
await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects a timed-out index history request', async () => {
jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError'));
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('rejects duplicate or out-of-order trading dates', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, status: 200,
json: () => Promise.resolve({
'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] },
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] },
}),
} as Response);
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
.rejects.toThrow();
});
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => { it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
jest.spyOn(global, 'fetch').mockResolvedValue({ jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true, ok: true,
@@ -340,59 +273,4 @@ describe('MoexClient', () => {
expect(starts).toEqual(['0', '1']); expect(starts).toEqual(['0', '1']);
}, },
); );
it('filters non-RUB coupons and maturity out of bond events', async () => {
const pages: Record<string, unknown> = {
coupons: Response.json({
'coupons': {
columns: ['coupondate', 'value', 'faceunit'],
data: [
['2099-02-01', '2', 'SUR'],
['2099-02-02', '3', 'USD'],
],
},
'coupons.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 2, 100]],
},
}),
offers: Response.json({
'offers': {
columns: ['offerdate'],
data: [['2099-02-28']],
},
'offers.cursor': {
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
data: [[0, 1, 100]],
},
}),
description: Response.json({
description: {
columns: ['name', 'value'],
data: [
['MATDATE', '2099-02-28'],
['FACEVALUE', '500'],
['FACEUNIT', 'USD'],
],
},
}),
};
const fetchMock = jest
.spyOn(global, 'fetch')
.mockImplementation((input) => {
const url = new URL(input instanceof Request ? input.url : String(input));
const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description';
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map.
const page = pages[block] ?? pages.description;
return Promise.resolve(page as Response);
});
try {
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
{ type: 'offer', date: '2099-02-28' },
]);
} finally {
fetchMock.mockRestore();
}
});
}); });
+7 -68
View File
@@ -1,7 +1,7 @@
import { Injectable, ServiceUnavailableException } from '@nestjs/common'; import { Injectable, ServiceUnavailableException } from '@nestjs/common';
import { constants as httpStatus } from 'node:http2'; import { constants as httpStatus } from 'node:http2';
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' } enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
const MIN_PRICE_PERCENT_EXCLUSIVE = 0; const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
const FIRST_PAGE_OFFSET = 0; const FIRST_PAGE_OFFSET = 0;
@@ -10,13 +10,6 @@ const EMPTY_PAGE_SIZE = 0;
const MIN_CURSOR_VALUE = 0; const MIN_CURSOR_VALUE = 0;
const FALLBACK_PAGE_SIZE = 100; const FALLBACK_PAGE_SIZE = 100;
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
const ISO_DATE_LENGTH = 10;
const ZERO_CLOSE = 0;
const ISO_DATE_START = 0;
const LAST_HISTORY_ROW = -1;
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
const baseUrl = 'https://iss.moex.com/iss'; const baseUrl = 'https://iss.moex.com/iss';
const timeoutMs = 10_000; const timeoutMs = 10_000;
@@ -35,64 +28,9 @@ export type MoexBondYield = {
tradeAt: string; tradeAt: string;
value: string; value: string;
}; };
export type MoexIndexClose = { close: string; tradeDate: string };
@Injectable() @Injectable()
export class MoexClient { export class MoexClient {
async indexHistory(
secid: 'IMOEX' | 'MCFTRR',
from: string,
till: string,
): Promise<MoexIndexClose[]> {
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
const rows: MoexIndexClose[] = [];
let start = FIRST_PAGE_OFFSET;
for (;;) {
const query = new URLSearchParams({
from,
till,
'start': String(start),
'iss.meta': 'off',
'history.columns': 'TRADEDATE,CLOSE',
});
const response = await this.request(`${baseUrl}/${path}?${query}`);
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const block = response.history;
if (
!block || !Array.isArray(block.columns) ||
block.columns.length !== INDEX_COLUMNS.length ||
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
const current = parseRows(block);
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
for (const row of current) {
const tradeDate = text(row.tradedate);
const close = numberText(row.close);
if (
!tradeDate || !isoDayPattern.test(tradeDate) ||
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
!close || Number(close) <= ZERO_CLOSE ||
(previousDate !== undefined && tradeDate <= previousDate)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
rows.push({ tradeDate, close });
previousDate = tradeDate;
}
const cursor = parseRows(response['history.cursor']);
const index = integer(cursor[0]?.index);
const total = integer(cursor[0]?.total);
const pageSize = integer(cursor[0]?.pagesize);
if (
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
current.length !== Math.min(pageSize, total - start)
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
if (index + pageSize >= total) return rows;
start = index + pageSize;
}
}
async bondYield( async bondYield(
secid: string, secid: string,
isin: string, isin: string,
@@ -157,9 +95,9 @@ export class MoexClient {
for (const row of coupons) { for (const row of coupons) {
const date = text(row.coupondate); const date = text(row.coupondate);
const value = numberText(row.value); const value = numberText(row.value);
// RUB-only: coupons in other currencies are filtered out at this ingest boundary. const currency = currencyText(row.faceunit);
if (date && value && currencyText(row.faceunit) === 'RUB') { if (date && value && currency) {
events.push({ type: 'coupon', date, value, currency: 'RUB' }); events.push({ type: 'coupon', date, value, currency });
} }
} }
for (const row of offers) { for (const row of offers) {
@@ -171,8 +109,9 @@ export class MoexClient {
); );
const date = values.get('MATDATE'); const date = values.get('MATDATE');
const value = numberText(values.get('FACEVALUE')); const value = numberText(values.get('FACEVALUE'));
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') { const currency = currencyText(values.get('FACEUNIT'));
events.push({ type: 'maturity', date, value, currency: 'RUB' }); if (date && value && currency) {
events.push({ type: 'maturity', date, value, currency });
} }
return events; return events;
} }
+1 -1
View File
@@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' } enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
const EXPECTED_REGISTRY_TABLES = 1; const EXPECTED_REGISTRY_TABLES = 1;
const RATING_COLUMN = 2; const RATING_COLUMN = 2;
+1 -9
View File
@@ -1,6 +1,4 @@
import type { Money } from '../../common/money.types'; import { Decimal } from '../../common/decimal';
import { Decimal, formatDecimal } from '../../common/decimal';
const MISSING_PROTO_COMPONENT = 0; const MISSING_PROTO_COMPONENT = 0;
@@ -8,12 +6,6 @@ const NANO = 1_000_000_000n;
export type ProtoDecimal = { nano?: number | string; units?: number | string }; export type ProtoDecimal = { nano?: number | string; units?: number | string };
export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null {
return value?.currency?.trim().toUpperCase() === 'RUB'
? { currency: 'RUB', value: formatDecimal(decimal(value)) }
: null;
}
export function decimal(value: ProtoDecimal): Decimal { export function decimal(value: ProtoDecimal): Decimal {
return { return {
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT), value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
@@ -5,15 +5,13 @@ import { isDeepStrictEqual } from 'node:util';
import type { import type {
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
} from '#generated/prisma/client'; Prisma,
} from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { toJson } from '../../prisma/to-json';
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
@@ -121,8 +119,11 @@ export class InstrumentMetadataRepository {
active: ReadonlySet<string>, active: ReadonlySet<string>,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const valid = await lockConnectionGeneration(tx, attemptOwner); const valid = await tx.tbankConnection.updateMany({
if (!valid) return null; where: { ...attemptOwner, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!valid.count) return null;
const scope = { const scope = {
...attemptOwner, ...attemptOwner,
scopeKey: `metadata:${metadataKey(identity)}`, scopeKey: `metadata:${metadataKey(identity)}`,
@@ -130,7 +131,11 @@ export class InstrumentMetadataRepository {
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
if (isJoinableRunningAttempt(previous, runId, active)) { if (
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
const data = { const data = {
@@ -166,11 +171,15 @@ export class InstrumentMetadataRepository {
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) { publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await claimSyncWork(tx, attempt, { const claimed = await tx.tbankSyncWork.updateMany({
state: 'complete', where: {
progress: {}, ...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
}); });
if (!claimed) return false; if (!claimed.count) return false;
const aliases = new Map( const aliases = new Map(
results.map((result) => [`${result.type}:${result.id}`, result]), results.map((result) => [`${result.type}:${result.id}`, result]),
); );
@@ -207,7 +216,9 @@ export class InstrumentMetadataRepository {
const previous = await tx.tbankInstrument.findUnique({ const previous = await tx.tbankInstrument.findUnique({
where: { provider_uid: key }, where: { provider_uid: key },
}); });
const payload = toJson(instrument); const payload = JSON.parse(
JSON.stringify(instrument),
) as Prisma.InputJsonObject;
const data = { const data = {
metadata: payload, metadata: payload,
figi: figi:
@@ -257,17 +268,18 @@ export class InstrumentMetadataRepository {
} }
async finishObserver(attempt: TbankSyncAttempt, success: boolean) { async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
await claimSyncWork( await this.prisma.tbankSyncWork.updateMany({
this.prisma, where: {
attempt, ...attempt,
{ state: 'running',
...(success ? { connection: { invalidToken: false } } : {}),
},
data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : TbankErrorCode.Unavailable, errorCode: success ? null : TbankErrorCode.Unavailable,
}, },
{ withoutConnectionFence: !success }, });
);
if (success) { if (success) {
// Fails sibling attempts whose connection was invalidated mid-flight.
await this.prisma.tbankSyncWork.updateMany({ await this.prisma.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
+35 -39
View File
@@ -12,7 +12,6 @@ import { PrismaService } from '../../prisma/prisma.service';
import { supportedTbankAccounts } from './tbank-account.policy'; import { supportedTbankAccounts } from './tbank-account.policy';
import { TbankErrorCode } from './tbank-error-code'; import { TbankErrorCode } from './tbank-error-code';
import { tbankSourceNeedsRefresh } from './tbank-source.policy'; import { tbankSourceNeedsRefresh } from './tbank-source.policy';
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
import { TbankTokenService } from './tbank-token.service'; import { TbankTokenService } from './tbank-token.service';
const EXPECTED_UPDATED_CONNECTIONS = 1; const EXPECTED_UPDATED_CONNECTIONS = 1;
@@ -26,22 +25,6 @@ export type TbankSyncAttempt = {
userId: string; userId: string;
}; };
// Join rule shared by sync reservations: the same process joins its own running attempt
// only while that attempt is still active in memory. Set and Map both satisfy `has`.
export function isJoinableRunningAttempt<
T extends { attemptId: string; processRunId: string; state: string },
>(
previous: null | T,
processRunId: string,
active: { has(attemptId: string): boolean },
): previous is T {
return (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
);
}
@Injectable() @Injectable()
export class TbankAccessService { export class TbankAccessService {
constructor( constructor(
@@ -57,8 +40,11 @@ export class TbankAccessService {
force: boolean, force: boolean,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const current = await lockConnectionGeneration(tx, { userId, revision }); const current = await tx.tbankConnection.updateMany({
if (!current) return null; where: { userId, revision, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const connection = await tx.tbankConnection.findUniqueOrThrow({ const connection = await tx.tbankConnection.findUniqueOrThrow({
where: { userId }, where: { userId },
}); });
@@ -76,7 +62,11 @@ export class TbankAccessService {
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
if (isJoinableRunningAttempt(previous, processRunId, active)) { if (
previous?.state === 'running' &&
previous.processRunId === processRunId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
const data = { const data = {
@@ -108,11 +98,15 @@ export class TbankAccessService {
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) { async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
if (invalidToken) return this.rejectMembershipToken(attempt); if (invalidToken) return this.rejectMembershipToken(attempt);
const result = await claimSyncWork(this.prisma, attempt, { const result = await this.prisma.tbankSyncWork.updateMany({
state: 'failed', where: {
errorCode: TbankErrorCode.Unavailable, ...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
}); });
return result === EXPECTED_UPDATED_CONNECTIONS; return result.count === EXPECTED_UPDATED_CONNECTIONS;
} }
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> { async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
@@ -127,21 +121,16 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null; return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
} }
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller. async onlineToken(userId: string) {
async online<T>(
userId: string,
run: (connection: { revision: string; token: string }) => Promise<T>,
): Promise<T> {
const connection = await this.prisma.tbankConnection.findUnique({ const connection = await this.prisma.tbankConnection.findUnique({
where: { userId }, where: { userId },
select: { encryptedToken: true, invalidToken: true, revision: true }, select: { encryptedToken: true, invalidToken: true },
}); });
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected }); if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) { if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken }); throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
} }
const token = this.tokens.decrypt(connection.encryptedToken); return this.tokens.decrypt(connection.encryptedToken);
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
} }
async publishMembership( async publishMembership(
@@ -157,8 +146,15 @@ export class TbankAccessService {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Acquire the write fence before reading membership. Replacement and // Acquire the write fence before reading membership. Replacement and
// attempt supersession cannot interleave with the publication below. // attempt supersession cannot interleave with the publication below.
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() }); const claimed = await tx.tbankSyncWork.updateMany({
if (!claimed) return false; where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { updatedAt: new Date() },
});
if (!claimed.count) return false;
const { userId, revision } = attempt; const { userId, revision } = attempt;
const previous = await tx.tbankAccount.findMany({ const previous = await tx.tbankAccount.findMany({
where: { userId, revision }, where: { userId, revision },
@@ -231,11 +227,11 @@ export class TbankAccessService {
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> { async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await claimSyncWork(tx, attempt, { const claimed = await tx.tbankSyncWork.updateMany({
state: 'failed', where: { ...attempt, state: 'running' },
errorCode: TbankErrorCode.InvalidToken, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}, { withoutConnectionFence: true }); });
if (!claimed) return false; if (!claimed.count) return false;
await tx.tbankConnection.update({ await tx.tbankConnection.update({
where: { where: {
userId_revision: { userId_revision: {
@@ -8,9 +8,7 @@ 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;
-10
View File
@@ -5,10 +5,6 @@ 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',
@@ -49,17 +45,11 @@ export enum TbankOperationType {
Dividend = 'OPERATION_TYPE_DIVIDEND', Dividend = 'OPERATION_TYPE_DIVIDEND',
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX', DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER', DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
InpMulti = 'OPERATION_TYPE_INP_MULTI',
Input = 'OPERATION_TYPE_INPUT', Input = 'OPERATION_TYPE_INPUT',
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES', InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
MarginFee = 'OPERATION_TYPE_MARGIN_FEE', MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
Output = 'OPERATION_TYPE_OUTPUT', Output = 'OPERATION_TYPE_OUTPUT',
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES', OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
Sell = 'OPERATION_TYPE_SELL', Sell = 'OPERATION_TYPE_SELL',
SellCard = 'OPERATION_TYPE_SELL_CARD', SellCard = 'OPERATION_TYPE_SELL_CARD',
SellMargin = 'OPERATION_TYPE_SELL_MARGIN', SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
@@ -1,54 +0,0 @@
import type { Prisma } from '#generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
const NO_UPDATED_ROWS = 0;
// Shared owners for the tbank-sync DB claim/lock protocol.
// Both helpers are CAS fences: they run inside the caller's transaction and
// keep the exact where/data shapes they replaced. Callers keep deciding
// success from the claim count exactly as before.
// Claims the active TbankSyncWork row: only the still-running attempt of a
// valid connection may transition. `data` is the state transition to apply.
export type ClaimOptions = {
// Overrides the default 'running' fence (e.g. an initiator row also
// accepts 'complete').
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
// true drops the `connection: { invalidToken: false }` guard for fences
// that must also fire on an invalidated connection.
withoutConnectionFence: boolean | undefined;
};
export async function claimSyncWork(
tx: Prisma.TransactionClient,
attempt: TbankSyncAttempt,
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
options: Partial<ClaimOptions> = {},
): Promise<number> {
const result = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: options.state ?? 'running',
...(options.withoutConnectionFence
? {}
: { connection: { invalidToken: false } }),
},
data,
});
return result.count;
}
// Locks the connection generation before reserving or publishing work: the
// row must still be the current revision with a valid token. `extra`
// (e.g. account membership) is merged into the where clause.
export async function lockConnectionGeneration(
tx: Prisma.TransactionClient,
owner: { revision: string; userId: string },
extra: Prisma.TbankConnectionWhereInput = {},
): Promise<boolean> {
const result = await tx.tbankConnection.updateMany({
where: { ...owner, ...extra, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
return result.count > NO_UPDATED_ROWS;
}
-1
View File
@@ -3,7 +3,6 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
export { metadataKey } from './instrument-metadata.service'; export { metadataKey } from './instrument-metadata.service';
export type { MetadataIdentity } from './instrument-metadata.service'; export type { MetadataIdentity } from './instrument-metadata.service';
export { export {
isJoinableRunningAttempt,
TbankAccessService, TbankAccessService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from './tbank-access.service'; } from './tbank-access.service';
+1 -214
View File
@@ -1,6 +1,5 @@
import { Metadata, status } from '@grpc/grpc-js'; import { status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { EventEmitter } from 'node:events';
import { TbankOperationState } from './tbank-protocol'; import { TbankOperationState } from './tbank-protocol';
import { TbankClient } from './tbank.client'; import { TbankClient } from './tbank.client';
@@ -137,70 +136,6 @@ describe('TbankClient operations', () => {
); );
jest.useRealTimers(); jest.useRealTimers();
}); });
it('distinguishes a gRPC deadline from queue delay without logging upstream secrets', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const channel = { getConnectivityState: () => 1 };
const service = {
getChannel: () => channel,
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: unknown) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => callback({ code: status.DEADLINE_EXCEEDED, details: 'Bearer secret' }));
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
jest.useFakeTimers();
try {
const failed = invoke('secret', service, 'getAccounts', {});
await Promise.all([
expect(failed).rejects.toBeInstanceOf(ServiceUnavailableException),
jest.advanceTimersByTimeAsync(1000),
]);
expect(logger.event).toHaveBeenLastCalledWith('error', 'tbank.rpc.failed', expect.objectContaining({
operation: 'getAccounts', grpcCode: status.DEADLINE_EXCEEDED,
queueWaitMs: 0, channelState: 'CONNECTING', headersReceived: false,
}));
const [, , fields] = logger.event.mock.calls.at(-1) as [string, string, { networkMs: unknown }];
expect(typeof fields.networkMs).toBe('number');
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
} finally {
jest.useRealTimers();
}
});
it('records response headers and a validated upstream tracking ID', async () => {
const logger = { event: jest.fn() };
const client = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger,
queue: { size: 0, run: (operation: () => Promise<unknown>) => operation() },
});
const headers = new Metadata();
headers.set('x-tracking-id', '123e4567-e89b-12d3-a456-426614174000');
headers.set('authorization', 'Bearer secret');
const service = {
getChannel: () => ({ getConnectivityState: () => 2 }),
getAccounts: (_input: unknown, _metadata: unknown, _options: unknown, callback: (error: null, response: object) => void) => {
const call = new EventEmitter();
void Promise.resolve().then(() => {
call.emit('metadata', headers);
callback(null, { accounts: [] });
});
return call;
},
};
const invoke = (client as unknown as { call: (token: string, service: unknown, method: string, input: object) => Promise<unknown> }).call.bind(client);
await expect(invoke('secret', service, 'getAccounts', {})).resolves.toEqual({ accounts: [] });
expect(logger.event).toHaveBeenLastCalledWith('debug', 'tbank.rpc.completed', expect.objectContaining({
channelState: 'READY', headersReceived: true,
trackingId: '123e4567-e89b-12d3-a456-426614174000',
}));
expect(JSON.stringify(logger.event.mock.calls)).not.toContain('secret');
});
}); });
describe('TbankClient metadata retries', () => { describe('TbankClient metadata retries', () => {
@@ -236,16 +171,6 @@ describe('TbankClient metadata retries', () => {
jest.useRealTimers(); jest.useRealTimers();
}); });
it.each([
['bondBy', (client: TbankClient, optIn: boolean) => client.getBond('token', 'uid', undefined, optIn)],
['etfBy', (client: TbankClient, optIn: boolean) => client.getEtf('token', 'uid', undefined, optIn)],
['getAssetBy', (client: TbankClient, optIn: boolean) => optIn ? client.getAssetBrand('token', 'uid') : client.getAssetDescription('token', 'uid')],
])('%s reports NOT_FOUND only for classification lookups', async (_method, invoke) => {
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
await expect(invoke(client, false)).rejects.toBeInstanceOf(ServiceUnavailableException);
await expect(invoke(client, true)).rejects.toBeInstanceOf(NotFoundException);
});
it.each([ it.each([
status.UNAVAILABLE, status.UNAVAILABLE,
status.DEADLINE_EXCEEDED, status.DEADLINE_EXCEEDED,
@@ -357,141 +282,3 @@ 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',
);
});
});
+10 -102
View File
@@ -2,7 +2,6 @@ import {
ChannelCredentials, ChannelCredentials,
Client, Client,
ClientUnaryCall, ClientUnaryCall,
connectivityState,
loadPackageDefinition, loadPackageDefinition,
Metadata, Metadata,
ServiceError, ServiceError,
@@ -35,7 +34,6 @@ import {
InstrumentIdType, InstrumentIdType,
TbankAccountStatus, TbankAccountStatus,
TbankCandleInterval, TbankCandleInterval,
TbankInstrumentStatus,
TbankInstrumentType, TbankInstrumentType,
TbankOperationState, TbankOperationState,
} from './tbank-protocol'; } from './tbank-protocol';
@@ -61,13 +59,6 @@ type Unary<Request, Response> = (
callback: (error: null | ServiceError, response: Response) => void, callback: (error: null | ServiceError, response: Response) => void,
) => ClientUnaryCall; ) => ClientUnaryCall;
type TbankClientService = Client & Record<string, Unary<never, never>>; type TbankClientService = Client & Record<string, Unary<never, never>>;
type RpcDiagnostics = {
channelState?: string;
headersReceived?: boolean;
networkMs?: number;
queueWaitMs?: number;
trackingId?: string;
};
export type TbankDecimal = { nano?: number | string; units?: number | string }; export type TbankDecimal = { nano?: number | string; units?: number | string };
export type TbankMoney = TbankDecimal & { currency?: string }; export type TbankMoney = TbankDecimal & { currency?: string };
export type TbankAccount = { export type TbankAccount = {
@@ -334,35 +325,6 @@ 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,
@@ -375,29 +337,18 @@ export class TbankClient {
).then((response) => response.instruments ?? []); ).then((response) => response.instruments ?? []);
} }
getBond(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankBond> { getBond(token: string, uid: string): Promise<null | TbankBond> {
return this.call<{ instrument?: TbankBond }>( return this.call<{ instrument?: TbankBond }>(
token, token,
this.sparseInstruments, this.sparseInstruments,
'bondBy', 'bondBy',
{ {
idType, idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
notFound,
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getEtf(
token: string,
id: string,
idType = InstrumentIdType.Uid,
notFound = false,
): Promise<null | Pick<TbankShare, 'assetUid' | 'figi' | 'sector' | 'uid'>> {
return this.call<{ instrument?: TbankShare }>(token, this.sparseInstruments, 'etfBy', { idType, id }, notFound)
.then((response) => response.instrument ?? null);
}
getBondCoupons( getBondCoupons(
token: string, token: string,
uid: string, uid: string,
@@ -416,25 +367,18 @@ export class TbankClient {
).then((response) => response.events ?? []); ).then((response) => response.events ?? []);
} }
getShare(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankShare> { getShare(token: string, uid: string): Promise<null | TbankShare> {
return this.call<{ instrument?: TbankShare }>( return this.call<{ instrument?: TbankShare }>(
token, token,
this.instruments, this.instruments,
'shareBy', 'shareBy',
{ {
idType, idType: InstrumentIdType.Uid,
id: uid, id: uid,
}, },
notFound,
).then((response) => response.instrument ?? null); ).then((response) => response.instrument ?? null);
} }
getAssetBrand(token: string, assetUid: string): Promise<null | { assetUid?: string; brand?: { name?: string; uid?: string } }> {
return this.call<{ asset?: { brand?: { name?: string; uid?: string }; uid?: string } }>(
token, this.instruments, 'getAssetBy', { id: assetUid }, true,
).then((response) => response.asset ? { assetUid: response.asset.uid, brand: response.asset.brand } : null);
}
getAssetDescription(token: string, assetUid: string): Promise<null | string> { getAssetDescription(token: string, assetUid: string): Promise<null | string> {
return this.call<{ asset?: { description?: string } }>( return this.call<{ asset?: { description?: string } }>(
token, token,
@@ -532,29 +476,20 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
notFound = false,
): Promise<Response> { ): Promise<Response> {
const metadataCall = method === 'getInstrumentBy'; const metadataCall = method === 'getInstrumentBy';
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) { for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
const startedAt = performance.now(); const startedAt = performance.now();
const queueFull = this.queue.size >= MAX_QUEUE_SIZE; const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
const diagnostics: RpcDiagnostics = {};
try { try {
if (queueFull) { if (queueFull) {
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable }); throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
} }
const response = await this.queue.run(() => { const response = await this.queue.run(() =>
const sentAt = performance.now(); this.request<Response>(token, client, method, request),
diagnostics.queueWaitMs = Math.round(sentAt - startedAt); );
return this.request<Response>(
token, client, method, request, diagnostics,
).finally(() => {
diagnostics.networkMs = Math.round(performance.now() - sentAt);
});
});
this.logger.event('debug', 'tbank.rpc.completed', { this.logger.event('debug', 'tbank.rpc.completed', {
operation: method, operation: method,
...diagnostics,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
}); });
@@ -572,7 +507,6 @@ export class TbankClient {
if (retryable && attempts < RPC_MAX_ATTEMPTS) { if (retryable && attempts < RPC_MAX_ATTEMPTS) {
this.logger.event('warn', 'tbank.rpc.retry', { this.logger.event('warn', 'tbank.rpc.retry', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts, attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
@@ -582,7 +516,6 @@ export class TbankClient {
} }
this.logger.event('error', 'tbank.rpc.failed', { this.logger.event('error', 'tbank.rpc.failed', {
operation: method, operation: method,
...diagnostics,
...failureContext, ...failureContext,
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts, attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
durationMs: Math.round(performance.now() - startedAt), durationMs: Math.round(performance.now() - startedAt),
@@ -595,7 +528,7 @@ export class TbankClient {
} }
if ( if (
grpcCode === status.NOT_FOUND && grpcCode === status.NOT_FOUND &&
(notFound || method === 'shareBy' || metadataCall) (method === 'shareBy' || metadataCall)
) { ) {
throw new NotFoundException(); throw new NotFoundException();
} }
@@ -610,42 +543,17 @@ export class TbankClient {
client: TbankClientService, client: TbankClientService,
method: string, method: string,
request: object, request: object,
diagnostics?: RpcDiagnostics,
): Promise<Response> { ): Promise<Response> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const metadata = new Metadata(); const metadata = new Metadata();
metadata.set('Authorization', `Bearer ${token}`); metadata.set('Authorization', `Bearer ${token}`);
// A channel state is a snapshot, not proof that this particular call reached the server.
const channel = diagnostics && client.getChannel();
if (diagnostics) diagnostics.headersReceived = false;
// eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input. // eslint-disable-next-line security/detect-object-injection -- Service and method names are internal RPC selections, not request input.
const call = client[method]( client[method](
request as never, request as never,
metadata, metadata,
{ deadline: new Date(Date.now() + requestDeadlineMs) }, { deadline: new Date(Date.now() + requestDeadlineMs) },
(error, response) => { (error, response) => (error ? reject(error) : resolve(response)),
if (diagnostics && channel) {
diagnostics.channelState = connectivityState[
channel.getConnectivityState(false)
];
}
if (error) reject(error);
else resolve(response);
},
); );
if (diagnostics) {
call.on('metadata', (headers: Metadata) => {
diagnostics.headersReceived = true;
const trackingId = headers.get('x-tracking-id')[0];
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
if (
typeof trackingId === 'string' &&
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
) {
diagnostics.trackingId = trackingId;
}
});
}
}); });
} }
} }
+1 -1
View File
@@ -37,7 +37,7 @@ export function configureApp(app: INestApplication): void {
exceptionFactory: (message) => new BadRequestException({ message }), exceptionFactory: (message) => new BadRequestException({ message }),
}), }),
); );
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger))); app.useGlobalFilters(new HttpExceptionFilter());
app.enableShutdownHooks(); app.enableShutdownHooks();
} }
@@ -2,14 +2,12 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt'; import { JwtService } from '@nestjs/jwt';
import { createHash } from 'crypto'; import { createHash } from 'crypto';
import { AuthRepository } from '../infrastructure/auth.repository';
jest.mock('bcrypt', () => ({ jest.mock('bcrypt', () => ({
hash: jest.fn(() => Promise.resolve('password-hash')), hash: jest.fn(() => Promise.resolve('password-hash')),
compare: jest.fn(() => Promise.resolve(true)), compare: jest.fn(() => Promise.resolve(true)),
})); }));
jest.mock('#src/prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
@@ -53,7 +51,7 @@ describe('AuthService', () => {
delete: jest.fn(), delete: jest.fn(),
}, },
}; };
const service = new AuthService(new AuthRepository(prisma as never), jwt); const service = new AuthService(prisma as never, jwt);
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
+62 -18
View File
@@ -7,16 +7,17 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import type { Session, User } from '#generated/prisma/client'; import { Session, User } from '../../../../generated/prisma/client';
import { HttpErrorCode } from '../../../common/error-code';
import { HttpErrorCode } from '#src/common/error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service';
import { AuthRepository } from '../infrastructure/auth.repository';
import { AuthErrorCode } from './auth-error-code'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
const EXPECTED_ROTATED_SESSIONS = 1;
const PASSWORD_HASH_ROUNDS = 12; const PASSWORD_HASH_ROUNDS = 12;
export const REFRESH_TTL_MS = 604_800_000; const REFRESH_TTL_MS = 604_800_000;
const ACCESS_TTL = '15m'; const ACCESS_TTL = '15m';
const REFRESH_TTL = '7d'; const REFRESH_TTL = '7d';
@@ -27,7 +28,7 @@ const DUMMY_PASSWORD_HASH =
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
private readonly repository: AuthRepository, private readonly prisma: PrismaService,
private readonly jwt: JwtService, private readonly jwt: JwtService,
) {} ) {}
@@ -35,19 +36,28 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const existing = await this.repository.findUserByEmail(email); const existing = await this.prisma.user.findUnique({
where: { email },
});
if (existing) { if (existing) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
const user = await this.repository.createUser({ let user: User;
try {
user = await this.prisma.user.create({
data: {
id: randomUUID(), id: randomUUID(),
email, email,
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS), passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
},
}); });
if (!user) { } catch (error) {
if (isUniqueConstraintError(error)) {
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered }); throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
} }
throw error;
}
return this.createSession(user); return this.createSession(user);
} }
@@ -55,7 +65,9 @@ export class AuthService {
email: string, email: string,
password: string, password: string,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const user = await this.repository.findUserByEmail(email); const user = await this.prisma.user.findUnique({
where: { email },
});
const valid = await bcrypt.compare( const valid = await bcrypt.compare(
password, password,
user?.passwordHash ?? DUMMY_PASSWORD_HASH, user?.passwordHash ?? DUMMY_PASSWORD_HASH,
@@ -80,7 +92,10 @@ export class AuthService {
if (payload.type !== 'refresh') { if (payload.type !== 'refresh') {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
const session = await this.repository.findSessionWithUser(payload.sid); const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
include: { user: true },
});
if ( if (
!session || !session ||
session.userId !== payload.sub || session.userId !== payload.sub ||
@@ -88,7 +103,7 @@ export class AuthService {
session.expiresAt <= new Date() session.expiresAt <= new Date()
) { ) {
if (session && !session.revokedAt) { if (session && !session.revokedAt) {
await this.repository.deleteSession(session.id); await this.prisma.session.delete({ where: { id: session.id } });
} }
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
@@ -109,13 +124,18 @@ export class AuthService {
return; return;
} }
if (payload.type !== 'refresh') return; if (payload.type !== 'refresh') return;
const session = await this.repository.findSession(payload.sid); const session = await this.prisma.session.findUnique({
where: { id: payload.sid },
});
if ( if (
session && session &&
!session.revokedAt && !session.revokedAt &&
session.refreshTokenHash === this.hash(token) session.refreshTokenHash === this.hash(token)
) { ) {
await this.repository.revokeSession(session.id); await this.prisma.session.update({
where: { id: session.id },
data: { revokedAt: new Date() },
});
} }
} }
@@ -124,7 +144,7 @@ export class AuthService {
} }
async userById(id: string): Promise<User> { async userById(id: string): Promise<User> {
const user = await this.repository.findUserById(id); const user = await this.prisma.user.findUnique({ where: { id } });
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
return user; return user;
} }
@@ -132,7 +152,14 @@ export class AuthService {
private async createSession( private async createSession(
user: User, user: User,
): Promise<AuthenticationResult> { ): Promise<AuthenticationResult> {
const session = await this.repository.createSession(user.id, randomUUID()); const session = await this.prisma.session.create({
data: {
id: randomUUID(),
userId: user.id,
refreshTokenHash: '',
expiresAt: new Date(),
},
});
return this.rotateSession({ ...session, user }); return this.rotateSession({ ...session, user });
} }
@@ -153,9 +180,17 @@ export class AuthService {
refreshTokenHash: this.hash(refreshToken), refreshTokenHash: this.hash(refreshToken),
expiresAt: new Date(Date.now() + REFRESH_TTL_MS), expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
}; };
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) { if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized }); throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
} }
} else {
await this.prisma.session.update({ where: { id: session.id }, data });
}
return { return {
user: this.toUser(session.user), user: this.toUser(session.user),
accessToken: await this.jwt.signAsync( accessToken: await this.jwt.signAsync(
@@ -170,3 +205,12 @@ export class AuthService {
return createHash('sha256').update(token).digest('hex'); return createHash('sha256').update(token).digest('hex');
} }
} }
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
+1 -2
View File
@@ -5,7 +5,6 @@ import { PassportModule } from '@nestjs/passport';
import { Environment } from '../../config/env.validation'; import { Environment } from '../../config/env.validation';
import { AuthService } from './application/auth.service'; import { AuthService } from './application/auth.service';
import { AuthRepository } from './infrastructure/auth.repository';
import { JwtStrategy } from './infrastructure/jwt.strategy'; import { JwtStrategy } from './infrastructure/jwt.strategy';
import { AuthController } from './presentation/auth.controller'; import { AuthController } from './presentation/auth.controller';
import { JwtAuthGuard } from './presentation/jwt-auth.guard'; import { JwtAuthGuard } from './presentation/jwt-auth.guard';
@@ -23,6 +22,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
], ],
exports: [JwtAuthGuard], exports: [JwtAuthGuard],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard], providers: [AuthService, JwtStrategy, JwtAuthGuard],
}) })
export class AuthModule {} export class AuthModule {}
@@ -1,81 +0,0 @@
import { Injectable } from '@nestjs/common';
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '#src/prisma/prisma.service';
const EXPECTED_ROTATED_SESSIONS = 1;
@Injectable()
export class AuthRepository {
constructor(private readonly prisma: PrismaService) {}
findUserByEmail(email: string) {
return this.prisma.user.findUnique({ where: { email } });
}
async createUser(data: { email: string; id: string; passwordHash: string }) {
try {
return await this.prisma.user.create({ data });
} catch (error) {
if (isUniqueConstraintError(error)) return null;
throw error;
}
}
findUserById(id: string) {
return this.prisma.user.findUnique({ where: { id } });
}
findSessionWithUser(id: string) {
return this.prisma.session.findUnique({
where: { id },
include: { user: true },
});
}
findSession(id: string) {
return this.prisma.session.findUnique({ where: { id } });
}
deleteSession(id: string) {
return this.prisma.session.delete({ where: { id } });
}
revokeSession(id: string) {
return this.prisma.session.update({
where: { id },
data: { revokedAt: new Date() },
});
}
createSession(userId: string, id: string) {
return this.prisma.session.create({
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
});
}
async rotateSession(
id: string,
data: { expiresAt: Date; refreshTokenHash: string },
expectedRefreshTokenHash?: string,
): Promise<boolean> {
if (expectedRefreshTokenHash) {
const updated = await this.prisma.session.updateMany({
where: { id, refreshTokenHash: expectedRefreshTokenHash },
data,
});
return updated.count === EXPECTED_ROTATED_SESSIONS;
}
await this.prisma.session.update({ where: { id }, data });
return true;
}
}
function isUniqueConstraintError(error: unknown): boolean {
return (
typeof error === 'object' &&
error !== null &&
'code' in error &&
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
);
}
@@ -3,9 +3,8 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '#src/common/error-code'; import { HttpErrorCode } from '../../../common/error-code';
import { Environment } from '#src/config/env.validation'; import { Environment } from '../../../config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
@@ -20,12 +20,11 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '#src/common/error-code'; import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service';
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { LoginDto, RegisterDto } from './auth.dto'; import { CredentialsDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard'; import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token'; const REFRESH_COOKIE = 'refresh_token';
@@ -57,7 +56,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async register( async register(
@Body() credentials: RegisterDto, @Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -70,7 +69,7 @@ export class AuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ApiOkResponse({ type: AuthenticationDto }) @ApiOkResponse({ type: AuthenticationDto })
async login( async login(
@Body() credentials: LoginDto, @Body() credentials: CredentialsDto,
@Res({ passthrough: true }) response: Response, @Res({ passthrough: true }) response: Response,
) { ) {
return this.respond( return this.respond(
@@ -135,7 +134,6 @@ export class AuthController {
sameSite: 'lax' as const, sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development', secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH, path: COOKIE_PATH,
maxAge: REFRESH_TTL_MS,
}; };
} }
} }
+10 -28
View File
@@ -1,9 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { Transform } from 'class-transformer'; import { Transform } from 'class-transformer';
import { import {
IsNotEmpty,
IsString, IsString,
Matches, MinLength,
Validate, Validate,
ValidatorConstraint, ValidatorConstraint,
} from 'class-validator'; } from 'class-validator';
@@ -14,10 +13,8 @@ const FIRST_CHARACTER_INDEX = 0;
const MAX_EMAIL_LENGTH = 254; const MAX_EMAIL_LENGTH = 254;
const ASCII_SPACE = 32; const ASCII_SPACE = 32;
const ASCII_DELETE = 127; const ASCII_DELETE = 127;
// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes. const MIN_PASSWORD_LENGTH = 12;
const MIN_PASSWORD_LENGTH = 8;
const MAX_PASSWORD_BYTES = 72; const MAX_PASSWORD_BYTES = 72;
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
@ValidatorConstraint({ name: 'email' }) @ValidatorConstraint({ name: 'email' })
class EmailConstraint { class EmailConstraint {
@@ -43,9 +40,8 @@ class EmailConstraint {
} }
} }
// Login stays lenient: accounts created under an older policy must still sign in. @ValidatorConstraint({ name: 'passwordBytes' })
@ValidatorConstraint({ name: 'passwordMaxBytes' }) class PasswordBytesConstraint {
class PasswordMaxBytesConstraint {
validate(value: unknown): boolean { validate(value: unknown): boolean {
return ( return (
typeof value === 'string' && typeof value === 'string' &&
@@ -54,11 +50,11 @@ class PasswordMaxBytesConstraint {
} }
defaultMessage(): string { defaultMessage(): string {
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`; return 'must not exceed 72 UTF-8 bytes';
} }
} }
class EmailDto { export class CredentialsDto {
@ApiProperty({ example: 'user@example.com' }) @ApiProperty({ example: 'user@example.com' })
@IsString() @IsString()
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
@@ -66,28 +62,14 @@ class EmailDto {
) )
@Validate(EmailConstraint) @Validate(EmailConstraint)
declare email: string; declare email: string;
}
export class RegisterDto extends EmailDto {
@ApiProperty({ @ApiProperty({
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`, minLength: MIN_PASSWORD_LENGTH,
pattern: PASSWORD_PATTERN, maxLength: MAX_PASSWORD_BYTES,
example: 'a secure password', example: 'a secure password',
}) })
@IsString() @IsString()
@Matches(new RegExp(PASSWORD_PATTERN), { @MinLength(MIN_PASSWORD_LENGTH)
message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`, @Validate(PasswordBytesConstraint)
})
declare password: string;
}
export class LoginDto extends EmailDto {
@ApiProperty({
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
example: 'a secure password',
})
@IsString()
@IsNotEmpty()
@Validate(PasswordMaxBytesConstraint)
declare password: string; declare password: string;
} }
@@ -5,8 +5,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { import {
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH,
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { BankCard, Prisma } from '#generated/prisma/client'; import { BankCard, Prisma } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { bankBalance } from '../domain/bank-card.calculations'; import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
@@ -20,13 +20,24 @@ import {
missing, missing,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_CARDS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankCardsService { export class BankCardsService {
constructor(private readonly repository: BankCardsRepository) {} constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> { private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
const rows = await this.repository.cardOperations(tx, card); const rows = await tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
return { return {
id: card.id, id: card.id,
name: card.name, name: card.name,
@@ -42,15 +53,20 @@ export class BankCardsService {
} }
list(userId: string, includeArchived = false) { list(userId: string, includeArchived = false) {
return this.repository.read(async (tx) => return this.prisma.$transaction(async (tx) =>
Promise.all((await this.repository.cards(tx, userId, includeArchived)).map( Promise.all(
(card) => this.cardView(tx, card), (
)), await tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
})
).map((card) => this.cardView(tx, card)),
),
); );
} }
get(userId: string, id: string) { get(userId: string, id: string) {
return this.repository.read(async (tx) => return this.prisma.$transaction(async (tx) =>
this.cardView(tx, await this.repository.card(tx, userId, id)), this.cardView(tx, await this.repository.card(tx, userId, id)),
); );
} }
@@ -59,7 +75,7 @@ export class BankCardsService {
const input = cardInput(dto), const input = cardInput(dto),
hash = fingerprint(input); hash = fingerprint(input);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await this.repository.cardById(tx, dto.id); const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -72,11 +88,8 @@ export class BankCardsService {
await this.repository.seed(tx, userId); await this.repository.seed(tx, userId);
return this.cardView( return this.cardView(
tx, tx,
await this.repository.createCard(tx, { await tx.bankCard.create({
...input, data: { ...input, id: dto.id, userId, createFingerprint: hash },
id: dto.id,
userId,
createFingerprint: hash,
}), }),
); );
}); });
@@ -109,16 +122,20 @@ export class BankCardsService {
} }
currentDate(input.openingDate); currentDate(input.openingDate);
if ( if (
await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate) await tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: input.openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
})
) { ) {
fail(BankErrorCode.OpeningDateConflict); fail(BankErrorCode.OpeningDateConflict);
} }
const updated = await this.repository.updateCard( const changed = await tx.bankCard.updateMany({
tx, where: { id, userId, version: dto.version },
userId, data: {
id,
dto.version,
{
...input, ...input,
paymentSystem: input.paymentSystem ?? null, paymentSystem: input.paymentSystem ?? null,
version: { increment: 1 }, version: { increment: 1 },
@@ -130,8 +147,9 @@ export class BankCardsService {
: null, : null,
}), }),
}, },
); });
return this.cardView(tx, updated); if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.cardView(tx, await this.repository.card(tx, userId, id));
}); });
} }
} }
@@ -1,16 +1,12 @@
import { GoneException, Injectable } from '@nestjs/common'; import { GoneException, Injectable } from '@nestjs/common';
import type { import {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '#generated/prisma/client'; } from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits'; import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
import {
operationFieldConflict,
operationKindChanged,
} from '../domain/bank-operation.rules';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { import {
@@ -32,39 +28,43 @@ import {
periodBounds, periodBounds,
} from './bank-card-input'; } from './bank-card-input';
const EXPECTED_UPDATED_ROWS = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
@Injectable() @Injectable()
export class BankOperationsService { export class BankOperationsService {
constructor(private readonly repository: BankCardsRepository) {} constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
private async operationView( private async operationView(
tx: Tx, tx: Tx,
row: BankCardOperation, row: BankCardOperation,
): Promise<BankOperationDto> { ): Promise<BankOperationDto> {
return (await this.pageViews(tx, [row]))[0]; const card = await this.repository.card(tx, row.userId, row.cardId);
} const target = row.targetCardId
? await this.repository.card(tx, row.userId, row.targetCardId)
private async pageViews(tx: Tx, rows: BankCardOperation[]) { : null;
const userId = rows[0]?.userId;
if (!userId) return [];
const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
const cardsById = new Map(cards.map((card) => [card.id, card]));
const sourcesById = new Map(sources.map((source) => [source.id, source]));
const categoriesById = new Map(categories.map((category) => [category.id, category]));
return rows.map((row) => {
const card = cardsById.get(row.cardId) as { name: string };
const target = row.targetCardId ? cardsById.get(row.targetCardId) : null;
const source = row.originalExpenseId const source = row.originalExpenseId
? sourcesById.get(row.originalExpenseId) ? await this.repository.operation(tx, row.userId, row.originalExpenseId)
: null; : null;
const categoryId = source?.categoryId ?? row.categoryId; const categoryId = source?.categoryId ?? row.categoryId;
const relatedRefunds = const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
: null;
const refunds =
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source
? refunds.filter( ? await tx.bankCardOperation.findMany({
(refund) => where: {
refund.originalExpenseId === (source?.id ?? row.id) && userId: row.userId,
(!source || refund.id !== row.id), originalExpenseId: source?.id ?? row.id,
) deletedAt: null,
...(source ? { id: { not: row.id } } : {}),
},
})
: []; : [];
return { return {
id: row.id, id: row.id,
@@ -74,17 +74,14 @@ export class BankOperationsService {
targetCardId: row.targetCardId, targetCardId: row.targetCardId,
targetCardName: target?.name ?? null, targetCardName: target?.name ?? null,
categoryId, categoryId,
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null, categoryName: category?.name ?? null,
originalExpenseId: row.originalExpenseId, originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks), amount: rubMoney(row.amountKopecks),
availableRefund: availableRefund:
row.kind === BankOperationKind.Expense || source row.kind === BankOperationKind.Expense || source
? rubMoney( ? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) - (source?.amountKopecks ?? row.amountKopecks) -
relatedRefunds.reduce( refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
(sum, refund) => sum + refund.amountKopecks,
ZERO_KOPECKS,
),
) )
: null, : null,
date: row.date, date: row.date,
@@ -92,7 +89,6 @@ export class BankOperationsService {
version: row.version, version: row.version,
createdAt: row.createdAt.toISOString(), createdAt: row.createdAt.toISOString(),
}; };
});
} }
private async validateOperation( private async validateOperation(
@@ -116,28 +112,49 @@ export class BankOperationsService {
fail(BankErrorCode.InvalidDate); fail(BankErrorCode.InvalidDate);
} }
} }
if (old && operationKindChanged(old.kind, input.kind, if (
old.originalExpenseId, input.originalExpenseId)) { old &&
(old.kind !== input.kind ||
old.originalExpenseId !== input.originalExpenseId)
) {
fail(BankErrorCode.ImmutableOperation); fail(BankErrorCode.ImmutableOperation);
} }
if (operationFieldConflict(input)) fail(); if (input.kind === BankOperationKind.Transfer) {
if (
!input.targetCardId ||
input.targetCardId === input.cardId ||
input.categoryId ||
input.originalExpenseId
) {
fail();
}
} else if (input.targetCardId) fail();
if (input.kind === BankOperationKind.Expense) { if (input.kind === BankOperationKind.Expense) {
if (input.originalExpenseId) fail();
if (!input.categoryId) { if (!input.categoryId) {
const category = await this.repository.defaultCategory(tx, userId); const category = await tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
if (!category) missing(); if (!category) missing();
input.categoryId = category.id; input.categoryId = category.id;
} }
if ( if (
!(await this.repository.category(tx, userId, input.categoryId)) !(await tx.expenseCategory.findFirst({
where: { userId, id: input.categoryId },
}))
) { ) {
missing(); missing();
} }
} else if (input.categoryId) fail();
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
fail();
} }
if (input.kind === BankOperationKind.Refund) { if (input.kind === BankOperationKind.Refund) {
if (!input.originalExpenseId) fail();
const source = await this.repository.operation( const source = await this.repository.operation(
tx, tx,
userId, userId,
input.originalExpenseId as string, input.originalExpenseId,
); );
if ( if (
source.deletedAt || source.deletedAt ||
@@ -146,11 +163,29 @@ export class BankOperationsService {
) { ) {
fail(BankErrorCode.InvalidRefund); fail(BankErrorCode.InvalidRefund);
} }
await this.repository.updateOperation(tx, if (
{ id: source.id, userId, version: source.version, deletedAt: null }, (
{ version: { increment: 1 } }, await tx.bankCardOperation.updateMany({
); where: {
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id); id: source.id,
userId,
version: source.version,
deletedAt: null,
},
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
const refunds = await tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId: source.id,
deletedAt: null,
...(old ? { id: { not: old.id } } : {}),
},
});
if (input.date < source.date) fail(BankErrorCode.InvalidDate); if (input.date < source.date) fail(BankErrorCode.InvalidDate);
if ( if (
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
@@ -161,7 +196,9 @@ export class BankOperationsService {
} }
} }
if (old?.kind === BankOperationKind.Expense) { if (old?.kind === BankOperationKind.Expense) {
const refunds = await this.repository.refunds(tx, userId, old.id); const refunds = await tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: old.id, deletedAt: null },
});
if ( if (
refunds.length && refunds.length &&
(input.cardId !== old.cardId || (input.cardId !== old.cardId ||
@@ -178,7 +215,9 @@ export class BankOperationsService {
const normalized = operationInput(dto), const normalized = operationInput(dto),
hash = fingerprint(normalized); hash = fingerprint(normalized);
return this.repository.write(async (tx) => { return this.repository.write(async (tx) => {
const existing = await this.repository.operationById(tx, dto.id); const existing = await tx.bankCardOperation.findUnique({
where: { id: dto.id },
});
if (existing) { if (existing) {
if (existing.userId !== userId) missing(); if (existing.userId !== userId) missing();
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
@@ -194,15 +233,15 @@ export class BankOperationsService {
await this.validateOperation(tx, userId, input); await this.validateOperation(tx, userId, input);
return this.operationView( return this.operationView(
tx, tx,
await this.repository.createOperation(tx, { await tx.bankCardOperation.create({
...input, id: dto.id, userId, createFingerprint: hash, data: { ...input, id: dto.id, userId, createFingerprint: hash },
}), }),
); );
}); });
} }
getOperation(userId: string, id: string) { getOperation(userId: string, id: string) {
return this.repository.read(async (tx) => { return this.prisma.$transaction(async (tx) => {
const row = await this.repository.operation(tx, userId, id); const row = await this.repository.operation(tx, userId, id);
if (row.deletedAt) missing(); if (row.deletedAt) missing();
return this.operationView(tx, row); return this.operationView(tx, row);
@@ -227,10 +266,16 @@ export class BankOperationsService {
description: dto.description ?? old.description ?? undefined, description: dto.description ?? old.description ?? undefined,
}); });
await this.validateOperation(tx, userId, input, old); await this.validateOperation(tx, userId, input, old);
await this.repository.updateOperation(tx, if (
{ id, userId, version: dto.version, deletedAt: null }, (
{ ...input, version: { increment: 1 } }, await tx.bankCardOperation.updateMany({
); where: { id, userId, version: dto.version, deletedAt: null },
data: { ...input, version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
return this.operationView( return this.operationView(
tx, tx,
await this.repository.operation(tx, userId, id), await this.repository.operation(tx, userId, id),
@@ -249,7 +294,9 @@ export class BankOperationsService {
} }
if ( if (
row.kind === BankOperationKind.Expense && row.kind === BankOperationKind.Expense &&
(await this.repository.dependentRefund(tx, userId, id)) (await tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId: id, deletedAt: null },
}))
) { ) {
conflict(BankErrorCode.DependentRefunds); conflict(BankErrorCode.DependentRefunds);
} }
@@ -259,15 +306,27 @@ export class BankOperationsService {
userId, userId,
row.originalExpenseId, row.originalExpenseId,
); );
await this.repository.updateOperation(tx, if (
{ id: source.id, userId, version: source.version }, (
{ version: { increment: 1 } }, await tx.bankCardOperation.updateMany({
); where: { id: source.id, userId, version: source.version },
data: { version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
}
}
if (
(
await tx.bankCardOperation.updateMany({
where: { id, userId, version, deletedAt: null },
data: { deletedAt: new Date(), version: { increment: 1 } },
})
).count !== EXPECTED_UPDATED_ROWS
) {
conflict();
} }
await this.repository.updateOperation(tx,
{ id, userId, version, deletedAt: null },
{ deletedAt: new Date(), version: { increment: 1 } },
);
}); });
} }
@@ -280,15 +339,29 @@ export class BankOperationsService {
const period = expenses const period = expenses
? undefined ? undefined
: periodBounds(query as BankOperationsQueryDto); : periodBounds(query as BankOperationsQueryDto);
return this.repository.read(async (tx) => { return this.prisma.$transaction(async (tx) => {
await this.repository.card(tx, userId, id); await this.repository.card(tx, userId, id);
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
page = query.page ?? FIRST_PAGE; page = query.page ?? FIRST_PAGE;
const { total, rows } = await this.repository.operationPage( const where: Prisma.BankCardOperationWhereInput = {
tx, userId, id, period, limit, page, userId,
); deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { return {
items: await this.pageViews(tx, rows), items: await Promise.all(
rows.map((row) => this.operationView(tx, row)),
),
total, total,
page, page,
hasNext: page * limit < total, hasNext: page * limit < total,
@@ -1,24 +1,81 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service';
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { name } from './bank-card-input'; import { conflict, missing, name } from './bank-card-input';
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
@Injectable() @Injectable()
export class ExpenseCategoriesService { export class ExpenseCategoriesService {
constructor(private readonly repository: BankCardsRepository) {} constructor(
private readonly prisma: PrismaService,
private readonly repository: BankCardsRepository,
) {}
async categories(userId: string) { async categories(userId: string) {
return this.repository.categories(userId); return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
} }
createCategory(userId: string, value: string) { createCategory(userId: string, value: string) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.createCategory(userId, label); return this.repository.write(async (tx) => {
await this.repository.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
} }
updateCategory(userId: string, id: string, value: string, version: number) { updateCategory(userId: string, id: string, value: string, version: number) {
const label = name(value, MAX_CATEGORY_NAME_LENGTH); const label = name(value, MAX_CATEGORY_NAME_LENGTH);
return this.repository.updateCategory(userId, id, label, version); return this.repository.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
} }
} }
@@ -1,9 +1,8 @@
jest.mock('#src/prisma/prisma.service', () => ({ jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('#generated/prisma/client', () => ({ Prisma: {} })); jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '#src/prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
@@ -1,5 +1,4 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money'; import { ZERO_KOPECKS } from './rub-money';
@@ -1,141 +0,0 @@
import { BankOperationKind } from './bank-operation-kind';
import {
operationFieldConflict,
OperationFieldRule,
operationKindChanged,
} from './bank-operation.rules';
const cardId = 'card-1';
function fields(
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
) {
return { kind: BankOperationKind.Topup, cardId, ...overrides };
}
describe('operationFieldConflict', () => {
it('allows a valid topup', () => {
expect(operationFieldConflict(fields())).toBeNull();
});
it('rejects a topup with a purchase link', () => {
expect(
operationFieldConflict(
fields({ originalExpenseId: 'op-1' }),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('rejects a non-transfer with a target card', () => {
expect(
operationFieldConflict(fields({ targetCardId: 'card-2' })),
).toBe(OperationFieldRule.InvalidCombination);
});
describe('expense', () => {
it('allows the default category to be resolved by the service', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense }),
),
).toBeNull();
});
it('rejects a purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Expense,
categoryId: 'cat-1',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a plain categorized expense', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
),
).toBeNull();
});
});
describe('transfer', () => {
it('requires a target card', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
).toBe(OperationFieldRule.MissingTargetCard);
});
it('rejects a transfer to the same card', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: cardId,
}),
),
).toBe(OperationFieldRule.SameTargetCard);
});
it('rejects a transfer with a category or purchase link', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Transfer,
targetCardId: 'card-2',
originalExpenseId: 'op-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
});
describe('refund', () => {
it('requires a purchase link', () => {
expect(
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
).toBe(OperationFieldRule.MissingOriginalExpense);
});
it('rejects a category', () => {
expect(
operationFieldConflict(
fields({
kind: BankOperationKind.Refund,
originalExpenseId: 'op-1',
categoryId: 'cat-1',
}),
),
).toBe(OperationFieldRule.InvalidCombination);
});
it('allows a linked refund', () => {
expect(
operationFieldConflict(
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
),
).toBeNull();
});
});
});
describe('operationKindChanged', () => {
it('flags kind and purchase link changes', () => {
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
});
});
@@ -1,79 +0,0 @@
import { BankOperationKind } from './bank-operation-kind';
export type OperationFields = {
cardId: string;
categoryId?: null | string;
kind: BankOperationKind;
originalExpenseId?: null | string;
targetCardId?: null | string;
};
export enum OperationFieldRule {
InvalidCombination = 'invalidCombination',
MissingOriginalExpense = 'missingOriginalExpense',
MissingTargetCard = 'missingTargetCard',
SameTargetCard = 'sameTargetCard',
}
function transferConflict(fields: OperationFields): null | OperationFieldRule {
if (!fields.targetCardId) {
return OperationFieldRule.MissingTargetCard;
}
if (fields.targetCardId === fields.cardId) {
return OperationFieldRule.SameTargetCard;
}
if (fields.categoryId || fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
return null;
}
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
if (fields.originalExpenseId) {
return OperationFieldRule.InvalidCombination;
}
// Expense without a category uses the user's default category in the service.
return null;
}
function refundConflict(fields: OperationFields): null | OperationFieldRule {
return fields.originalExpenseId
? null
: OperationFieldRule.MissingOriginalExpense;
}
/**
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
* Returns the first violated rule, or null when the combination is allowed.
*/
export function operationFieldConflict(
fields: OperationFields,
): null | OperationFieldRule {
if (fields.kind === BankOperationKind.Transfer) {
return transferConflict(fields);
}
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Expense) {
return expenseConflict(fields);
}
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
if (fields.kind === BankOperationKind.Refund) {
return refundConflict(fields);
}
// Topup: no target, no category, no purchase link.
return fields.originalExpenseId
? OperationFieldRule.InvalidCombination
: null;
}
export function operationKindChanged(
oldKind: string,
newKind: string,
oldOriginalExpenseId: null | string,
newOriginalExpenseId: null | string,
): boolean {
return (
oldKind !== newKind ||
oldOriginalExpenseId !== newOriginalExpenseId
);
}
@@ -1,6 +1,6 @@
import type { Money } from '#src/common/money.types'; import type { Money } from '../../../../common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal'; import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
export const ZERO_KOPECKS = 0n; export const ZERO_KOPECKS = 0n;
@@ -1,24 +1,18 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client'; import { Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '#src/prisma/prisma.service'; import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
missing, missing,
} from '../application/bank-card-input'; } from '../application/bank-card-input';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
const INITIAL_RETRY_COUNT = 0; const INITIAL_RETRY_COUNT = 0;
const LAST_SEED_OFFSET = 1; const LAST_SEED_OFFSET = 1;
const EXPECTED_UPDATED_CARDS = 1;
const EXPECTED_UPDATED_OPERATIONS = 1;
const EXPECTED_UPDATED_CATEGORIES = 1;
const VERSION_INCREMENT = 1;
type Tx = Prisma.TransactionClient; type Tx = Prisma.TransactionClient;
const TRANSACTION_MAX_WAIT_MS = 10_000; const TRANSACTION_MAX_WAIT_MS = 10_000;
@@ -38,220 +32,6 @@ const seeds = [
@Injectable() @Injectable()
export class BankCardsRepository { export class BankCardsRepository {
constructor(private readonly prisma: PrismaService) {} constructor(private readonly prisma: PrismaService) {}
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
return this.prisma.$transaction(run);
}
cards(tx: Tx, userId: string, includeArchived = false) {
return tx.bankCard.findMany({
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
});
}
cardOperations(tx: Tx, card: BankCard) {
return tx.bankCardOperation.findMany({
where: {
userId: card.userId,
deletedAt: null,
OR: [{ cardId: card.id }, { targetCardId: card.id }],
},
});
}
operationById(tx: Tx, id: string) {
return tx.bankCardOperation.findUnique({ where: { id } });
}
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
return tx.bankCardOperation.create({ data });
}
async updateOperation(
tx: Tx,
where: Prisma.BankCardOperationWhereInput,
data: Prisma.BankCardOperationUpdateManyMutationInput,
) {
const result = await tx.bankCardOperation.updateMany({ where, data });
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
}
category(tx: Tx, userId: string, id: string) {
return tx.expenseCategory.findFirst({ where: { id, userId } });
}
defaultCategory(tx: Tx, userId: string) {
return tx.expenseCategory.findUnique({
where: { userId_seedKey: { userId, seedKey: 'other' } },
});
}
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
return tx.bankCardOperation.findMany({
where: {
userId,
originalExpenseId,
deletedAt: null,
...(excludeId ? { id: { not: excludeId } } : {}),
},
});
}
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
return tx.bankCardOperation.findFirst({
where: { userId, originalExpenseId, deletedAt: null },
});
}
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
const cardIds = new Set(rows.flatMap((row) =>
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
));
const sourceIds = new Set(rows.flatMap((row) =>
row.originalExpenseId ? [row.originalExpenseId] : [],
));
const [cards, sources] = await Promise.all([
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
]);
const categoryIds = new Set(rows.map((row) =>
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
).filter((id): id is string => id !== null));
const expenseIds = new Set(rows.flatMap((row) => {
const source = sources.find((item) => item.id === row.originalExpenseId);
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
}));
const [categories, refunds] = await Promise.all([
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
tx.bankCardOperation.findMany({
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
}),
]);
return { cards, sources, categories, refunds };
}
async operationPage(
tx: Tx,
userId: string,
id: string,
period: undefined | { from: string; to: string },
limit: number,
page: number,
) {
const where: Prisma.BankCardOperationWhereInput = {
userId,
deletedAt: null,
OR: [{ cardId: id }, { targetCardId: id }],
...(period
? { date: { gte: period.from, lte: period.to } }
: { kind: BankOperationKind.Expense }),
};
const total = await tx.bankCardOperation.count({ where });
const rows = await tx.bankCardOperation.findMany({
where,
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
skip: (page - FIRST_PAGE) * limit,
take: limit,
});
return { total, rows };
}
cardById(tx: Tx, id: string) {
return tx.bankCard.findUnique({ where: { id } });
}
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
return tx.bankCard.create({ data });
}
async updateCard(
tx: Tx,
userId: string,
id: string,
version: number,
data: Prisma.BankCardUpdateManyMutationInput,
) {
const changed = await tx.bankCard.updateMany({
where: { id, userId, version },
data,
});
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
return this.card(tx, userId, id);
}
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
return tx.bankCardOperation.findFirst({
where: {
userId,
deletedAt: null,
date: { lt: openingDate },
OR: [{ cardId: id }, { targetCardId: id }],
},
});
}
async categories(userId: string) {
return (
await this.prisma.expenseCategory.findMany({
where: { userId },
orderBy: [{ name: 'asc' }, { id: 'asc' }],
})
).map(({ id, name, seedKey, version }) => ({
id,
name,
isDefault: seedKey === 'other',
version,
}));
}
createCategory(userId: string, label: string) {
return this.write(async (tx) => {
await this.seed(tx, userId);
const row = await tx.expenseCategory.create({
data: {
id: randomUUID(),
userId,
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
},
});
return {
id: row.id,
name: row.name,
isDefault: false,
version: row.version,
};
});
}
updateCategory(userId: string, id: string, label: string, version: number) {
return this.write(async (tx) => {
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
if (!row) missing();
if (
(
await tx.expenseCategory.updateMany({
where: { id, userId, version },
data: {
name: label,
nameKey: label.toLocaleLowerCase('ru-RU'),
version: { increment: 1 },
},
})
).count !== EXPECTED_UPDATED_CATEGORIES
) {
conflict();
}
return {
id,
name: label,
isDefault: row.seedKey === 'other',
version: version + VERSION_INCREMENT,
};
});
}
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> { async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
try { try {
@@ -1,7 +1,6 @@
import { import {
ApiProperty, ApiProperty,
ApiPropertyOptional, ApiPropertyOptional,
getSchemaPath,
IntersectionType, IntersectionType,
OmitType, OmitType,
PartialType, PartialType,
@@ -24,8 +23,7 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '#src/common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
FIRST_PAGE, FIRST_PAGE,
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
@@ -155,49 +153,6 @@ export class CreateBankOperationDto {
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH) @MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
description?: string; description?: string;
} }
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
// invalid combinations keep the existing BANK_INVALID_INPUT response.
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
] as const) {}
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
}
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
}
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
}
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
}
export const createBankOperationSchema = {
oneOf: [
CreateExpenseOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
CreateRefundOperationDto,
].map((model) => ({ $ref: getSchemaPath(model) })),
discriminator: {
propertyName: 'kind',
mapping: {
expense: getSchemaPath(CreateExpenseOperationDto),
topup: getSchemaPath(CreateTopupOperationDto),
transfer: getSchemaPath(CreateTransferOperationDto),
refund: getSchemaPath(CreateRefundOperationDto),
},
},
};
export class UpdateBankOperationDto extends PartialType( export class UpdateBankOperationDto extends PartialType(
OmitType(CreateBankOperationDto, ['id'] as const), OmitType(CreateBankOperationDto, ['id'] as const),
{ skipNullProperties: false }, { skipNullProperties: false },
@@ -16,9 +16,7 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { import {
ApiBearerAuth, ApiBearerAuth,
ApiBody,
ApiCreatedResponse, ApiCreatedResponse,
ApiExtraModels,
ApiNoContentResponse, ApiNoContentResponse,
ApiOkResponse, ApiOkResponse,
ApiResponse, ApiResponse,
@@ -29,9 +27,8 @@ import {
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '#src/common/decorators/api-safe-error-response.decorator'; } from '../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service'; import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service'; import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service'; import { ExpenseCategoriesService } from '../application/expense-categories.service';
@@ -49,11 +46,6 @@ import {
CategoryNameDto, CategoryNameDto,
CreateBankCardDto, CreateBankCardDto,
CreateBankOperationDto, CreateBankOperationDto,
createBankOperationSchema,
CreateExpenseOperationDto,
CreateRefundOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
ExpenseCategoryDto, ExpenseCategoryDto,
UpdateBankCardDto, UpdateBankCardDto,
UpdateBankOperationDto, UpdateBankOperationDto,
@@ -81,12 +73,6 @@ type AuthRequest = Request & { user: AccessPayload };
}, },
}) })
@ApiTags('bank-cards') @ApiTags('bank-cards')
@ApiExtraModels(
CreateExpenseOperationDto,
CreateTopupOperationDto,
CreateTransferOperationDto,
CreateRefundOperationDto,
)
@ApiBearerAuth() @ApiBearerAuth()
@ApiProblemResponses() @ApiProblemResponses()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@@ -145,7 +131,6 @@ export class BankCardsController {
} }
@Post('operations') @Post('operations')
@ApiBody({ schema: createBankOperationSchema })
@ApiCreatedResponse({ type: BankOperationDto }) @ApiCreatedResponse({ type: BankOperationDto })
createOperation( createOperation(
@Req() req: AuthRequest, @Req() req: AuthRequest,
@@ -1,37 +1,12 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '#src/common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto'; import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
export class UnrealizedReturnDto {
@ApiProperty({ example: '12.345678901' })
declare absolute: string;
@ApiProperty({ example: '100.00' })
declare costBasis: string;
@ApiProperty({ type: String, nullable: true, example: '12.34' })
declare percent: null | string;
@ApiProperty()
declare partial: boolean;
}
export class PortfolioSummaryDto { export class PortfolioSummaryDto {
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto, isArray: true })
declare totals: MoneyDto; declare totalsByCurrency: MoneyDto[];
@ApiProperty({ type: MoneyDto })
declare cash: MoneyDto;
@ApiProperty({ type: PortfolioStructureDto })
declare structure: PortfolioStructureDto;
@ApiProperty({ type: UnrealizedReturnDto })
declare unrealizedReturn: UnrealizedReturnDto;
@ApiProperty({ @ApiProperty({
type: 'integer', type: 'integer',
@@ -46,20 +21,6 @@ export class PortfolioSummaryDto {
description: 'Number of non-zero bond positions, not the number of units.', description: 'Number of non-zero bond positions, not the number of units.',
}) })
declare bondPositionCount: number; declare bondPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of non-zero fund positions, not the number of units.',
})
declare fundPositionCount: number;
@ApiProperty({
type: 'integer',
minimum: 0,
description: 'Number of all non-zero positions, not the number of units.',
})
declare totalPositionCount: number;
} }
export class BrokerageAccountDto { export class BrokerageAccountDto {
@@ -3,8 +3,7 @@ import type { Request } from 'express';
import { Controller, Get, 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 '#src/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 { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,7 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '#src/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 { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
@@ -1,4 +1,4 @@
jest.mock('#src/prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -33,7 +33,6 @@ describe('saved brokerage account projection', () => {
}); });
it('keeps exact saved totals and independent missing summaries', async () => { it('keeps exact saved totals and independent missing summaries', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValue({ portfolio.saved.mockResolvedValue({
membership: meta, membership: meta,
accounts: [ accounts: [
@@ -44,10 +43,10 @@ describe('saved brokerage account projection', () => {
openedOn: null, openedOn: null,
meta, meta,
snapshot: { snapshot: {
cash: { currency: 'RUB', value: '10.000000001' }, totalsByCurrency: [
structure, { currency: 'RUB', value: '100000000.000000001' },
totals: { currency: 'RUB', value: '100000000.000000001' }, ],
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }], positions: [{ type: 'STOCK' }, { type: 'BOND' }],
}, },
}, },
{ {
@@ -62,82 +61,15 @@ describe('saved brokerage account projection', () => {
}); });
const response = await service.list('owner'); const response = await service.list('owner');
expect(response.data?.[0].portfolioSummary).toEqual({ expect(response.data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '10.000000001' }, totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
structure,
totals: { currency: 'RUB', value: '100000000.000000001' },
stockPositionCount: 1, stockPositionCount: 1,
bondPositionCount: 1, bondPositionCount: 1,
fundPositionCount: 0,
totalPositionCount: 2,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
}); });
expect(response.data?.[1].portfolioSummary).toBeNull(); expect(response.data?.[1].portfolioSummary).toBeNull();
expect(response.meta.availability).toBe('available'); expect(response.meta.availability).toBe('available');
expect(response.meta.completeness).toBe('complete'); expect(response.meta.completeness).toBe('complete');
}); });
it('returns an empty summary from an empty saved portfolio', async () => {
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
stockPositionCount: 0, bondPositionCount: 0,
fundPositionCount: 0, totalPositionCount: 0,
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
});
});
it('calculates exact returns, partial data, and zero cost basis', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
positions: [
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
],
},
}],
});
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
stockPositionCount: 1, bondPositionCount: 2,
fundPositionCount: 1, totalPositionCount: 4,
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
});
});
it('omits return percent for short positions', async () => {
portfolio.saved.mockResolvedValueOnce({
membership: meta,
accounts: [{
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
snapshot: {
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
positions: [
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
],
},
}],
});
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
{ absolute: '-19', costBasis: '-190', percent: null, partial: false },
);
});
it('observes the latest hourly walk without starting another one', async () => { it('observes the latest hourly walk without starting another one', async () => {
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] }); portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
sync.hourly.mockResolvedValue({ sync.hourly.mockResolvedValue({
@@ -1,64 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import {
add,
decimalFromString,
divide,
formatDecimal,
isZero,
multiply,
} from '#src/common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api'; import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { import { BrokerageAccountDto, BrokerageAccountsResourceDto } from './brokerage-account.dto';
BrokerageAccountDto,
BrokerageAccountsResourceDto,
UnrealizedReturnDto,
} from './brokerage-account.dto';
const PERCENT_DECIMAL_PLACES = 2;
const ZERO_QUANTITY = 0n;
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
const absoluteReturn = positions.reduce(
(sum, position) => position.absoluteReturn
? add(sum, decimalFromString(position.absoluteReturn.value))
: sum,
decimalFromString('0'),
);
const costBasis = positions
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
.reduce(
(sum, position) =>
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
decimalFromString('0'),
);
const partial = positions.some(
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
);
// A short position has negative cost basis, so a percent would flip sign or net out.
const withShorts = positions.some(
(position) =>
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
decimalFromString(position.quantity).value < ZERO_QUANTITY,
);
return {
absolute: formatDecimal(absoluteReturn),
costBasis: formatDecimal(costBasis),
percent: isZero(costBasis) || withShorts
? null
: formatDecimal(divide(
multiply(absoluteReturn, decimalFromString('100')),
costBasis,
PERCENT_DECIMAL_PLACES,
)),
partial,
};
}
@Injectable() @Injectable()
export class BrokerageAccountsService { export class BrokerageAccountsService {
@@ -79,21 +25,13 @@ export class BrokerageAccountsService {
openedOn: account.openedOn, openedOn: account.openedOn,
portfolioSummary: account.snapshot portfolioSummary: account.snapshot
? { ? {
totals: account.snapshot.totals, totalsByCurrency: account.snapshot.totalsByCurrency,
cash: account.snapshot.cash,
structure: account.snapshot.structure,
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
stockPositionCount: account.snapshot.positions.filter( stockPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'STOCK', (position) => position.type === 'STOCK',
).length, ).length,
bondPositionCount: account.snapshot.positions.filter( bondPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'BOND', (position) => position.type === 'BOND',
).length, ).length,
fundPositionCount: account.snapshot.positions.filter(
(position) => position.type === 'FUND',
).length,
// The snapshot mapper already drops zero-quantity positions.
totalPositionCount: account.snapshot.positions.length,
} }
: null, : null,
})); }));
@@ -123,7 +61,6 @@ export class BrokerageAccountsService {
: null, : null,
freshForMs: 3_600_000, freshForMs: 3_600_000,
work: null, work: null,
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
dependencies: saved.accounts.map((account) => account.meta), dependencies: saved.accounts.map((account) => account.meta),
}), }),
sync: hourly sync: hourly
@@ -3,9 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/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 { import {
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto, IncomeAnalyticsResourceDto,
@@ -25,10 +25,12 @@ export class IncomeAnalyticsController {
@ApiOkResponse({ type: IncomeSummaryResourceDto }) @ApiOkResponse({ type: IncomeSummaryResourceDto })
allSummary( allSummary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
'all', 'all',
query.sync === 'observe',
); );
} }
@@ -43,6 +45,7 @@ export class IncomeAnalyticsController {
'all', 'all',
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
@@ -51,10 +54,12 @@ export class IncomeAnalyticsController {
summary( summary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
accountId, accountId,
query.sync === 'observe',
); );
} }
@@ -70,6 +75,7 @@ export class IncomeAnalyticsController {
accountId, accountId,
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
} }
@@ -1,17 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '#src/common/money.dto'; import { MoneyDto } from '../../../common/money.dto';
import { import {
InvestmentDependencyDto, InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../investment-resource.dto'; } from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api'; import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto { export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: description:
@@ -39,7 +39,10 @@ export class IncomePeriodDto {
declare to: string; declare to: string;
} }
export class IncomeTotalDto { export class IncomeCurrencySummaryDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: MoneyDto }) @ApiProperty({ type: MoneyDto })
declare coupon: MoneyDto; declare coupon: MoneyDto;
@@ -57,19 +60,41 @@ export class IncomeSummaryDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare total: IncomeTotalDto; declare totalsByCurrency: IncomeCurrencySummaryDto[];
@ApiProperty({ format: 'date-time' }) @ApiProperty({ format: 'date-time' })
declare calculatedAt: string; declare calculatedAt: string;
} }
export class IncomeComparisonCurrencyDto {
@ApiProperty()
declare currency: string;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare selected: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare compared: IncomeCurrencySummaryDto;
@ApiProperty({ type: IncomeCurrencySummaryDto })
declare difference: IncomeCurrencySummaryDto;
}
export class IncomeComparisonDto {
@ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeComparisonCurrencyDto, isArray: true })
declare totalsByCurrency: IncomeComparisonCurrencyDto[];
}
export class IncomeBucketDto { export class IncomeBucketDto {
@ApiProperty({ type: IncomePeriodDto }) @ApiProperty({ type: IncomePeriodDto })
declare period: IncomePeriodDto; declare period: IncomePeriodDto;
@ApiProperty({ type: IncomeTotalDto }) @ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
declare total: IncomeTotalDto; declare totalsByCurrency: IncomeCurrencySummaryDto[];
} }
export class IncomeInstrumentDto { export class IncomeInstrumentDto {
@@ -110,6 +135,12 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
} }
export class IncomeAnalyticsDto extends IncomeSummaryDto { export class IncomeAnalyticsDto extends IncomeSummaryDto {
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare previous: IncomeComparisonDto | null;
@ApiProperty({ type: IncomeComparisonDto, nullable: true })
declare yearComparison: IncomeComparisonDto | null;
@ApiProperty({ enum: ['day', 'week', 'month'] }) @ApiProperty({ enum: ['day', 'week', 'month'] })
declare bucketGranularity: 'day' | 'month' | 'week'; declare bucketGranularity: 'day' | 'month' | 'week';
@@ -131,7 +162,7 @@ export class IncomeSummaryResourceDto {
declare meta: InvestmentResourceMetaDto; declare meta: InvestmentResourceMetaDto;
} }
const incomeDependencyRoles = ['selected'] as const; const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
export class IncomeDependencyDto extends InvestmentDependencyDto { export class IncomeDependencyDto extends InvestmentDependencyDto {
@ApiProperty({ enum: incomeDependencyRoles }) @ApiProperty({ enum: incomeDependencyRoles })
@@ -1,4 +1,4 @@
jest.mock('#src/prisma/prisma.service', () => ({ jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('../operations/application/brokerage-operations.service', () => ({ jest.mock('../operations/application/brokerage-operations.service', () => ({
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
beforeEach(() => jest.clearAllMocks()); beforeEach(() => jest.clearAllMocks());
it('labels the selected dependency independently of its resource key', async () => { it('labels each comparison dependency independently of its resource key', async () => {
seed([]); seed([]);
const response = await service.analytics( const response = await service.analytics(
'user', 'user',
@@ -27,10 +27,32 @@ describe('IncomeAnalyticsService', () => {
); );
expect(response.meta.dependencies).toEqual([ expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }), expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
]); ]);
expect( });
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
).toBe(false); it('labels an unavailable leap-day comparison as year', async () => {
operations.collectRanges.mockImplementation(
(_user: string, _account: string, build: () => unknown[]) =>
build().map((_, index) => ({ items: [], meta: completeMeta(index) })),
);
const response = await service.analytics(
'user',
'account',
'2024-02-29',
'2024-02-29',
);
expect(response.meta.dependencies).toEqual([
expect.objectContaining({ role: 'selected' }),
expect.objectContaining({ role: 'previous' }),
expect.objectContaining({
role: 'year',
availability: 'temporarily-unavailable',
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
}),
]);
expect(response.data?.yearComparison).toBeNull();
}); });
function seed(items: BrokerageOperationDto[]) { function seed(items: BrokerageOperationDto[]) {
@@ -41,7 +63,7 @@ describe('IncomeAnalyticsService', () => {
build: (opened?: Date) => unknown[], build: (opened?: Date) => unknown[],
) => { ) => {
build(undefined); build(undefined);
return [items].map((items, index) => ({ return [items, [], []].map((items, index) => ({
items, items,
meta: completeMeta(index), meta: completeMeta(index),
})); }));
@@ -57,22 +79,22 @@ describe('IncomeAnalyticsService', () => {
ticker: 'T', ticker: 'T',
instrumentType: 'bond', instrumentType: 'bond',
}, },
key: 'uid:UID', key: 'uid:UID:RUB',
}, },
{ {
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' }, identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
key: 'figi:FIGI', key: 'figi:FIGI:RUB',
}, },
{ {
identity: { ticker: 'T', instrumentType: 'bond' }, identity: { ticker: 'T', instrumentType: 'bond' },
key: 'ticker:T:bond', key: 'ticker:T:bond:RUB',
}, },
{ identity: { ticker: 'T' }, key: 'ticker:T:' }, { identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
{ {
identity: { ticker: null, instrumentType: 'bond' }, identity: { ticker: null, instrumentType: 'bond' },
key: 'ticker::bond', key: 'ticker::bond:RUB',
}, },
{ identity: { ticker: null }, key: 'operation:receipt' }, { identity: { ticker: null }, key: 'operation:receipt:RUB' },
])( ])(
'uses one membership for entry and aggregate: $key', 'uses one membership for entry and aggregate: $key',
async ({ identity, key }) => { async ({ identity, key }) => {
@@ -102,7 +124,7 @@ describe('IncomeAnalyticsService', () => {
}, },
); );
it('preserves entry order, repeated IDs and signed exact totals', async () => { it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
const selected = [ const selected = [
{ {
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), ...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
@@ -112,6 +134,10 @@ describe('IncomeAnalyticsService', () => {
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'), ...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
instrumentUid: 'UID', instrumentUid: 'UID',
}, },
{
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
instrumentUid: 'UID',
},
]; ];
seed(selected); seed(selected);
@@ -127,7 +153,7 @@ describe('IncomeAnalyticsService', () => {
expect(result.entries).toEqual( expect(result.entries).toEqual(
selected.map((item) => ({ selected.map((item) => ({
...item, ...item,
instrumentKey: 'uid:UID', instrumentKey: `uid:UID:${item.payment?.currency}`,
})), })),
); );
expect( expect(
@@ -142,7 +168,14 @@ describe('IncomeAnalyticsService', () => {
), ),
).toEqual([ ).toEqual([
{ {
key: 'uid:UID', key: 'uid:UID:USD',
receiptCount: 1,
coupon: { currency: 'USD', value: '20' },
dividend: { currency: 'USD', value: '0' },
total: { currency: 'USD', value: '20' },
},
{
key: 'uid:UID:RUB',
receiptCount: 2, receiptCount: 2,
coupon: { currency: 'RUB', value: '10.25' }, coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-0.25' }, dividend: { currency: 'RUB', value: '-0.25' },
@@ -151,7 +184,7 @@ describe('IncomeAnalyticsService', () => {
]); ]);
expect( expect(
result.entries result.entries
.filter((item) => item.instrumentKey === 'uid:UID') .filter((item) => item.instrumentKey === 'uid:UID:RUB')
.map((item) => item.payment?.value), .map((item) => item.payment?.value),
).toEqual(['10.25', '-0.25']); ).toEqual(['10.25', '-0.25']);
}); });
@@ -180,32 +213,26 @@ describe('IncomeAnalyticsService', () => {
expect(response.data?.buckets).toEqual([ expect(response.data?.buckets).toEqual([
{ {
period: { from: '2026-01-14', to: '2026-01-14' }, period: { from: '2026-01-14', to: '2026-01-14' },
total: { totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.1' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0.1' }, total: { currency: 'RUB', value: '0.1' },
}, })],
}, },
{ {
period: { from: '2026-01-15', to: '2026-01-15' }, period: { from: '2026-01-15', to: '2026-01-15' },
total: { totalsByCurrency: [expect.objectContaining({
coupon: { currency: 'RUB', value: '0.2' }, coupon: { currency: 'RUB', value: '0.2' },
dividend: { currency: 'RUB', value: '-0.1' }, dividend: { currency: 'RUB', value: '-0.1' },
total: { currency: 'RUB', value: '0.1' }, total: { currency: 'RUB', value: '0.1' },
}, })],
}, },
{ {
period: { from: '2026-01-16', to: '2026-01-16' }, period: { from: '2026-01-16', to: '2026-01-16' },
total: { totalsByCurrency: [],
coupon: { currency: 'RUB', value: '0' },
dividend: { currency: 'RUB', value: '0' },
total: { currency: 'RUB', value: '0' },
},
}, },
]); ]);
}); });
it('aggregates only exact coupon and dividend payments', async () => { it('aggregates only exact coupon and dividend payments by source currency', async () => {
operations.collectRanges.mockImplementation( operations.collectRanges.mockImplementation(
( (
_user: string, _user: string,
@@ -218,7 +245,10 @@ describe('IncomeAnalyticsService', () => {
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'), entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'), entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'), entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
], ],
[],
[],
].map((items, index) => ({ items, meta: completeMeta(index) })); ].map((items, index) => ({ items, meta: completeMeta(index) }));
}, },
); );
@@ -232,14 +262,22 @@ describe('IncomeAnalyticsService', () => {
const result = response.data; const result = response.data;
assert(result); assert(result);
expect(result.total).toEqual({ expect(result.totalsByCurrency).toEqual([
expect.objectContaining({
currency: 'RUB',
coupon: { currency: 'RUB', value: '10.25' }, coupon: { currency: 'RUB', value: '10.25' },
dividend: { currency: 'RUB', value: '-2.5' }, dividend: { currency: 'RUB', value: '-2.5' },
total: { currency: 'RUB', value: '7.75' }, total: { currency: 'RUB', value: '7.75' },
}); }),
expect.objectContaining({
currency: 'USD',
total: { currency: 'USD', value: '1' },
}),
]);
expect(result.entries.map((entry) => entry.id)).toEqual([ expect(result.entries.map((entry) => entry.id)).toEqual([
'coupon', 'coupon',
'dividend', 'dividend',
'usd',
]); ]);
expect(result.buckets).toHaveLength(31); expect(result.buckets).toHaveLength(31);
}); });
@@ -6,9 +6,9 @@ import {
compare, compare,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
} from '#src/common/decimal'; subtract,
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; } from '../../../common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
@@ -18,11 +18,12 @@ import {
import { import {
IncomeAnalyticsEntryDto, IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto, IncomeAnalyticsResourceDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeDependencyDto, IncomeDependencyDto,
IncomeInstrumentDto, IncomeInstrumentDto,
IncomeSummaryDto, IncomeSummaryDto,
IncomeSummaryResourceDto, IncomeSummaryResourceDto,
IncomeTotalDto,
} from './income-analytics.dto'; } from './income-analytics.dto';
import { import {
IncomeBucketGranularity, IncomeBucketGranularity,
@@ -44,12 +45,17 @@ export class IncomeAnalyticsService {
async summary( async summary(
userId: string, userId: string,
accountId: string, accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> { ): Promise<IncomeSummaryResourceDto> {
const plan = planIncomePeriod(undefined, undefined); let plan = planIncomePeriod(undefined, undefined);
const [selected] = await this.operations.collectRanges( const [selected] = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
() => [plan.selected], (openedAt) => {
plan = planIncomePeriod(undefined, undefined, openedAt);
return [plan.selected];
},
observe,
); );
if (selected.items === null) return { data: null, meta: selected.meta }; if (selected.items === null) return { data: null, meta: selected.meta };
assert(selected.meta.lastSuccessfulAt); assert(selected.meta.lastSuccessfulAt);
@@ -69,33 +75,56 @@ export class IncomeAnalyticsService {
accountId: string, accountId: string,
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
observe = false,
): Promise<IncomeAnalyticsResourceDto> { ): Promise<IncomeAnalyticsResourceDto> {
const plan = planIncomePeriod(from, to); let plan = planIncomePeriod(from, to);
const [current] = await this.operations.collectRanges( const periods = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
() => [plan.selected], (openedAt) => {
plan = planIncomePeriod(from, to, openedAt);
return [
plan.selected,
plan.previous,
...(plan.yearAgo ? [plan.yearAgo] : []),
];
},
observe,
); );
const [current, prior, year] = periods;
// Preserve the legacy order while consumers migrate to explicit roles.
const dependencies: IncomeDependencyDto[] = [ const dependencies: IncomeDependencyDto[] = [
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
{ ...prior.meta, role: 'previous' },
{
...(plan.yearAgo && year
? year.meta
: investmentResourceMeta({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
dataRevision: 'unavailable',
availability: 'missing',
fetchedAt: null,
freshForMs: 0,
work: null,
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
})),
role: 'year',
},
]; ];
const work = current.meta.sync; const states = periods.map((period) => period.meta.sync);
const work =
states.find((state) => state.state === 'running') ??
states.find((state) => state.state === 'failed');
const meta = { const meta = {
...investmentResourceMeta({ ...investmentResourceMeta({
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`, resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
dataRevision: current.meta.dataRevision, dataRevision: current.meta.dataRevision,
availability: availability: current.meta.availability,
current.items === null
? 'temporarily-unavailable'
: 'available',
fetchedAt: current.meta.lastSuccessfulAt fetchedAt: current.meta.lastSuccessfulAt
? new Date(current.meta.lastSuccessfulAt) ? new Date(current.meta.lastSuccessfulAt)
: null, : null,
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS, freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
work: work.id work: work?.id ? { ...work, id: work.id } : null,
? { id: work.id, state: work.state, errorCode: work.errorCode }
: null,
lastAttempt: current.meta.lastAttempt,
coverage: current.meta.coverage, coverage: current.meta.coverage,
dependencies, dependencies,
}), }),
@@ -105,7 +134,7 @@ export class IncomeAnalyticsService {
assert(current.meta.lastSuccessfulAt); assert(current.meta.lastSuccessfulAt);
const selected = qualifying(current.items).map((entry) => ({ const selected = qualifying(current.items).map((entry) => ({
...entry, ...entry,
instrumentKey: instrumentKey(entry), instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
})); }));
return { return {
data: { data: {
@@ -115,6 +144,14 @@ export class IncomeAnalyticsService {
selected, selected,
current.meta.lastSuccessfulAt, current.meta.lastSuccessfulAt,
), ),
previous:
prior.items === null
? null
: comparison(selected, plan.previous, qualifying(prior.items)),
yearComparison:
plan.yearAgo && year?.items !== null && year
? comparison(selected, plan.yearAgo, qualifying(year.items))
: null,
bucketGranularity: plan.granularity, bucketGranularity: plan.granularity,
buckets: buckets(plan.selected, plan.granularity, selected), buckets: buckets(plan.selected, plan.granularity, selected),
instruments: instruments(selected), instruments: instruments(selected),
@@ -139,19 +176,55 @@ function summaryResponse(
entries: BrokerageOperationDto[], entries: BrokerageOperationDto[],
calculatedAt: string, calculatedAt: string,
): IncomeSummaryDto { ): IncomeSummaryDto {
return { accountId, period, total: totals(entries), calculatedAt }; return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
} }
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto { function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
const total = zeroTotal(); const values = new Map<string, Total>();
for (const entry of entries) { for (const entry of entries) {
if (!entry.payment) continue; if (!entry.payment) continue;
const total = values.get(entry.payment.currency) ?? zeroTotal();
const value = decimalFromString(entry.payment.value); const value = decimalFromString(entry.payment.value);
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) { if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
total.coupon = add(total.coupon, value); total.coupon = add(total.coupon, value);
} else total.dividend = add(total.dividend, value); } else total.dividend = add(total.dividend, value);
values.set(entry.payment.currency, total);
} }
return presentTotal(total); return [...values]
.sort(([left], [right]) => left.localeCompare(right))
.map(([currency, total]) => presentTotal(currency, total));
}
function comparison(
selectedEntries: BrokerageOperationDto[],
comparedPeriod: DateOnlyRange,
comparedEntries: BrokerageOperationDto[],
): IncomeComparisonDto {
const selected = new Map(
totals(selectedEntries).map((total) => [total.currency, total]),
);
const compared = new Map(
totals(comparedEntries).map((total) => [total.currency, total]),
);
const currencies = [
...new Set([...selected.keys(), ...compared.keys()]),
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
].sort();
return {
period: comparedPeriod,
totalsByCurrency: currencies.map((currency) => {
const current =
selected.get(currency) ?? presentTotal(currency, zeroTotal());
const prior =
compared.get(currency) ?? presentTotal(currency, zeroTotal());
return {
currency,
selected: current,
compared: prior,
difference: difference(current, prior),
};
}),
};
} }
function buckets( function buckets(
@@ -165,7 +238,7 @@ function buckets(
})); }));
return rangeBuckets(range, granularity).map((period) => ({ return rangeBuckets(range, granularity).map((period) => ({
period, period,
total: totals( totalsByCurrency: totals(
datedEntries datedEntries
.filter(({ date }) => date >= period.from && date <= period.to) .filter(({ date }) => date >= period.from && date <= period.to)
.map(({ entry }) => entry), .map(({ entry }) => entry),
@@ -185,7 +258,7 @@ function instruments(
return [...groups] return [...groups]
.map(([key, group]) => { .map(([key, group]) => {
const first = group[0]; const first = group[0];
const total = totals(group); const [total] = totals(group);
return { return {
key, key,
figi: first.figi, figi: first.figi,
@@ -217,14 +290,35 @@ function instrumentKey(entry: BrokerageOperationDto): string {
return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`; return `operation:${entry.account ? `${entry.account.id}:` : ''}${entry.id}`;
} }
function difference(
left: IncomeCurrencySummaryDto,
right: IncomeCurrencySummaryDto,
): IncomeCurrencySummaryDto {
return presentTotal(left.currency, {
coupon: subtract(
decimalFromString(left.coupon.value),
decimalFromString(right.coupon.value),
),
dividend: subtract(
decimalFromString(left.dividend.value),
decimalFromString(right.dividend.value),
),
});
}
function zeroTotal(): Total { function zeroTotal(): Total {
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } }; return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
} }
function presentTotal(value: Total): IncomeTotalDto { function presentTotal(
currency: string,
value: Total,
): IncomeCurrencySummaryDto {
const total = add(value.coupon, value.dividend);
return { return {
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) }, currency,
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) }, coupon: { currency, value: formatDecimal(value.coupon) },
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) }, dividend: { currency, value: formatDecimal(value.dividend) },
total: { currency, value: formatDecimal(total) },
}; };
} }
@@ -23,14 +23,16 @@ describe('income period planning', () => {
} }
}); });
it('keeps empty buckets and skips removed comparison ranges', () => { it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
const fullMonth = planIncomePeriod('2026-03-01', '2026-03-31');
expect(fullMonth.previous).toEqual({
from: '2026-02-01',
to: '2026-02-28',
});
expect(planIncomePeriod('2024-02-29', '2024-02-29').yearAgo).toBeNull();
expect( expect(
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'), rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
).toHaveLength(7); ).toHaveLength(7);
expect(planIncomePeriod('2026-03-01', '2026-03-31')).toEqual({
selected: { from: '2026-03-01', to: '2026-03-31' },
granularity: 'day',
});
}); });
it('accepts multi-year periods and creates monthly buckets for the entire range', () => { it('accepts multi-year periods and creates monthly buckets for the entire range', () => {
@@ -41,6 +43,7 @@ describe('income period planning', () => {
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' }); expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
expect(plan.granularity).toBe('month'); expect(plan.granularity).toBe('month');
expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72); expect(rangeBuckets(plan.selected, plan.granularity)).toHaveLength(72);
expect(plan.yearAgo).toEqual({ from: '2019-01-01', to: '2024-12-31' });
}); });
it('does not send the unfinished Moscow day as a future instant', () => { it('does not send the unfinished Moscow day as a future instant', () => {
@@ -1,7 +1,6 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -9,34 +8,51 @@ import {
utcDate, utcDate,
} from '../investment-calendar'; } from '../investment-calendar';
export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' } enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
const MONDAY_WEEKDAY_INDEX = 1; const MONDAY_WEEKDAY_INDEX = 1;
const INCLUSIVE_DAY_OFFSET = 1; const INCLUSIVE_DAY_OFFSET = 1;
const NEXT_DAY_OFFSET = 1; const NEXT_DAY_OFFSET = 1;
const PREVIOUS_MONTH_LAST_DAY = 0; const PREVIOUS_MONTH_LAST_DAY = 0;
const PREVIOUS_DAY_OFFSET = -1;
const PREVIOUS_YEAR_OFFSET = 1;
const DATE_COMPONENT_DIGITS = 2;
const DAILY_BUCKET_MAX_DAYS = 45; const DAILY_BUCKET_MAX_DAYS = 45;
const WEEKLY_BUCKET_MAX_DAYS = 180; const WEEKLY_BUCKET_MAX_DAYS = 180;
const DAYS_PER_WEEK = 7; const DAYS_PER_WEEK = 7;
const ISO_MONTH_LENGTH = 7; const ISO_MONTH_LENGTH = 7;
const ISO_YEAR_LENGTH = 4;
export type IncomeBucketGranularity = 'day' | 'month' | 'week'; export type IncomeBucketGranularity = 'day' | 'month' | 'week';
export function planIncomePeriod( export function planIncomePeriod(
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
openedDate?: Date,
): { ): {
granularity: IncomeBucketGranularity; granularity: IncomeBucketGranularity;
previous: DateOnlyRange;
selected: DateOnlyRange; selected: DateOnlyRange;
yearAgo: DateOnlyRange | null;
} { } {
if (Boolean(from) !== Boolean(to)) throw invalidPeriod(); if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
const today = moscowDate(new Date()); const today = moscowDate(new Date());
const selected = const selected =
from && to ? { from, to } : { from: firstOfMonth(today), to: today }; from && to ? { from, to } : { from: firstOfMonth(today), to: today };
assertRange(selected, today); assertRange(selected, today);
const previous = isFullMonth(selected)
? previousMonth(selected.from)
: shiftDays(selected, -rangeDays(selected));
const candidate = yearAgo(selected);
const yearAgoRange =
candidate && (!openedDate || candidate.from >= moscowDate(openedDate))
? candidate
: null;
return { return {
selected, selected,
previous,
yearAgo: yearAgoRange,
granularity: granularity:
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
? 'day' ? 'day'
@@ -120,6 +136,34 @@ function endOfMonth(value: string): string {
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH); return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
} }
function isFullMonth(range: DateOnlyRange): boolean {
return (
range.from === firstOfMonth(range.from) && range.to === endOfMonth(range.to)
);
}
function previousMonth(from: string): DateOnlyRange {
const last = addDays(firstOfMonth(from), PREVIOUS_DAY_OFFSET);
return { from: firstOfMonth(last), to: last };
}
function shiftDays(range: DateOnlyRange, days: number): DateOnlyRange {
return { from: addDays(range.from, days), to: addDays(range.to, days) };
}
function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
const shift = (value: string) => {
const [year, month, day] = dateParts(value);
const result = `${String(year - PREVIOUS_YEAR_OFFSET).padStart(ISO_YEAR_LENGTH, '0')}-${String(month).padStart(DATE_COMPONENT_DIGITS, '0')}-${String(day).padStart(DATE_COMPONENT_DIGITS, '0')}`;
return utcDate(result).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) === result
? result
: null;
};
const from = shift(range.from);
const to = shift(range.to);
return from && to ? { from, to } : null;
}
function invalidPeriod(): BadRequestException { function invalidPeriod(): BadRequestException {
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod }); return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
} }
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
jest.mock('./catalog-enrichment.service', () => ({ jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {}, CatalogEnrichmentService: class {},
})); }));
jest.mock('#src/integrations/tbank/tbank.api', () => ({ jest.mock('../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {}, TbankAccessService: class {},
TbankClient: class {}, TbankClient: class {},
})); }));
@@ -5,16 +5,15 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '#src/common/logging/structured-logger.service'; import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '#src/common/time'; import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
type TbankShare, type TbankShare,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
candleGaps, candleGaps,
@@ -30,7 +29,6 @@ import { timestamp } from './instrument-market';
const REPLACED_WINDOW_COUNT = 1; const REPLACED_WINDOW_COUNT = 1;
const NEXT_WINDOW_OFFSET = 1; const NEXT_WINDOW_OFFSET = 1;
const NO_CANDLE_GAPS = 0;
type CandleInput = { type CandleInput = {
instrumentType: 'bond' | 'share'; instrumentType: 'bond' | 'share';
@@ -99,7 +97,7 @@ export class CandleService {
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) {} ) {}
async read(userId: string, input: CandleInput) { async read(userId: string, input: CandleInput, observe = false) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const [detail] = await this.sources.readMany( const [detail] = await this.sources.readMany(
userId, userId,
@@ -116,10 +114,7 @@ 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 hasCompleteRange = Boolean( if (!observe && !saved) {
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
);
if (!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));
@@ -1,20 +0,0 @@
import { CatalogDiscoveryService } from './catalog-discovery.service';
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
describe('catalog discovery currency filtering', () => {
it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => {
const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' });
const client = {
findShares: jest.fn().mockResolvedValue([short('a'), short('b')]),
getShare: jest.fn().mockImplementation((_token: string, uid: string) =>
Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })),
getLastPrices: jest.fn().mockResolvedValue([]),
};
const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) };
const service = new CatalogDiscoveryService(access as never, client as never);
const search = await service.search('user', { instrumentType: 'share', query: 'SBER' });
expect(search.data.map((item) => item.uid)).toEqual(['b']);
expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b');
});
});
@@ -3,54 +3,156 @@ import {
Injectable, Injectable,
NotFoundException, NotFoundException,
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond,
TbankClient, TbankClient,
type TbankShare,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '#src/integrations/tbank/tbank.api'; type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { import {
CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
type CatalogQuery, type CatalogQuery,
catalogScope, catalogScope,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
catalogSearchLimit, catalogSearchLimit,
selectCatalogInstruments, selectCatalogInstruments,
} from './catalog-selection'; } from './catalog-selection';
import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const MISSING_DATA_REVISION = '0';
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
type Producer = {
attempt: TbankSyncAttempt;
force: boolean;
input: CatalogQuery;
observers: TbankSyncAttempt[];
};
@Injectable() @Injectable()
export class CatalogDiscoveryService { export class CatalogDiscoveryService {
private readonly runId = randomUUID();
private readonly active = new Set<string>();
private readonly producers = new Map<string, Producer>();
private readonly queue: Producer[] = [];
private reservations: Promise<unknown> = Promise.resolve();
private running = false;
private readonly completions = new Map<
string,
{ done: Promise<void>; resolve: () => void; workId: string }
>();
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly logger: StructuredLogger,
private readonly enrichment: CatalogEnrichmentService,
) {} ) {}
async search(userId: string, input: CatalogQuery) { async read(userId: string, input: CatalogQuery, observe = false) {
return this.onlineSearch(userId, catalogQuery(input)); input = catalogQuery(input);
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
if (!observe) await this.request(userId, connection.revision, input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
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 sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.completeness === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
} }
async detail( async detail(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
uid: string, uid: string,
observe = false,
) { ) {
const instrument = await this.access.online(userId, ({ token }) => if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share' instrumentType === 'share'
? this.tbank.getShare(token, uid) ? await this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid), : await this.tbank.getBond(token, uid);
); if (!instrument) throw new NotFoundException();
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException();
return { return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid }, data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -63,30 +165,76 @@ export class CatalogDiscoveryService {
}), }),
}; };
} }
const sources = await this.enrichment.readMany(
userId,
instrumentType,
[uid],
observe,
['detail'],
);
const source = sources[0];
if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(
userId,
{ instrumentType, query: instrument.ticker, exactTicker: true },
observe,
);
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
return {
data: instrument ? { ...instrument, uid, slug } : null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
// 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,
) { ) {
if (!observe) {
const query = ticker.trim().toUpperCase(); const query = ticker.trim().toUpperCase();
const found = await this.access.online(userId, ({ token }) => const token = await this.access.onlineToken(userId);
const found =
instrumentType === 'share' instrumentType === 'share'
? this.tbank.findShares(token, query) ? await this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query), : await this.tbank.findBonds(token, query);
); const matches = selectCatalogInstruments(
const candidates = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query), found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query, query,
instrumentType, instrumentType,
);
const details = await this.access.online(userId, ({ token }) => Promise.all(
candidates.map((item) => instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid)),
));
const matches = candidates.filter((_item, index) =>
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit); ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
if (!matches.length) throw new NotFoundException(); if (!matches.length) throw new NotFoundException();
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) { if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
@@ -100,9 +248,52 @@ export class CatalogDiscoveryService {
const result = await this.detail(userId, instrumentType, matches[0].uid); const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null }; return { ...result, data: result.data ? { ...result.data, slug: query } : null };
} }
const lookup = await this.read(
userId,
{ instrumentType, query: ticker, exactTicker: true },
observe,
);
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
code:
instrumentType === 'share'
? InstrumentErrorCode.StockTickerAmbiguous
: InstrumentErrorCode.BondTickerAmbiguous,
});
}
const result = lookup.data
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
}
private async onlineSearch(userId: string, input: CatalogQuery) { private async onlineSearch(userId: string, input: CatalogQuery) {
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => { const token = await this.access.onlineToken(userId);
const found = const found =
input.instrumentType === 'share' input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query) ? await this.tbank.findShares(token, input.query)
@@ -113,31 +304,26 @@ export class CatalogDiscoveryService {
: found, : found,
input.query, input.query,
input.instrumentType, input.instrumentType,
); ).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const [details, prices] = await Promise.all([ const details = await Promise.all(
Promise.all(
instruments.map((item) => instruments.map((item) =>
input.instrumentType === 'share' input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid) ? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid), : this.tbank.getBond(token, item.uid),
), ),
), );
instruments.length const prices = instruments.length
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid)) ? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [], : [];
]);
return { details, instruments, prices };
});
const fetchedAt = new Date(); const fetchedAt = new Date();
const data = instruments.flatMap((item, index) => { const data = instruments.flatMap((item, index) => {
const detail = details.at(index); const detail = details.at(index);
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
return [{ return [{
...item, ...item,
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null, detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
price: prices.find((price) => price.instrumentUid === item.uid) ?? null, price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
}]; }];
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit); });
return { return {
data, data,
meta: investmentResourceMeta({ meta: investmentResourceMeta({
@@ -150,4 +336,196 @@ export class CatalogDiscoveryService {
}), }),
}; };
} }
request(
userId: string,
revision: string,
query: CatalogQuery,
force = false,
) {
const input = catalogQuery(query);
const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input);
if (
!force &&
saved.dataRevision !== MISSING_DATA_REVISION
) return null;
const reservation = await this.repository.reserve(
{ userId, revision },
input,
this.runId,
this.active,
);
if (!reservation) return null;
const { id, attemptId } = reservation.work;
if (!reservation.launch) {
const current = this.producers.get(catalogScope(input));
if (current) current.force ||= force;
return id;
}
const attempt = { userId, revision, id, attemptId };
this.active.add(attemptId);
let resolve = () => {};
const done = new Promise<void>((finish) => {
resolve = finish;
});
this.completions.set(attemptId, { workId: id, done, resolve });
const key = catalogScope(input);
const producer = this.producers.get(key);
if (producer) {
producer.observers.push(attempt);
producer.force ||= force;
} else {
const queued = { input, attempt, observers: [], force };
this.producers.set(key, queued);
this.queue.push(queued);
}
if (!this.running) {
this.running = true;
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserved.catch(() => undefined);
return reserved;
}
async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true);
if (id) {
await Promise.all(
[...this.completions.values()]
.filter((entry) => entry.workId === id)
.map((entry) => entry.done),
);
}
const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany(
userId,
query.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
true,
);
return [
...(id ? [id] : []),
...sources.flatMap((source) =>
source.meta.sync.id ? [source.meta.sync.id] : [],
),
];
}
private async drain() {
try {
while (this.queue.length) {
const producer = this.queue.shift();
if (!producer) break;
const { input, attempt } = producer;
let success = false;
try {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker
? found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
)
: found,
input.query,
input.instrumentType,
).slice(
FIRST_RESULT_OFFSET,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
),
new Date(),
);
}
} catch (error) {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'catalog-discovery.failed', {
code: TbankErrorCode.Unavailable,
});
}
}
if (success) {
const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
await this.enrichment.requestMany(
observer.userId,
observer.revision,
input.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
);
}
}
}
this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) {
await this.repository.finish(observer, success);
this.active.delete(observer.attemptId);
this.completions.get(observer.attemptId)?.resolve();
this.completions.delete(observer.attemptId);
}
}
} catch {
for (const completion of this.completions.values()) completion.resolve();
this.completions.clear();
this.active.clear();
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: TbankErrorCode.Unavailable,
});
} finally {
this.running = false;
}
}
}
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,
};
} }
@@ -1,27 +1,43 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '#src/common/logging/structured-logger.service'; import {
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; decimalFromString,
divide,
} from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '../../../../common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond,
type TbankCandle,
TbankClient, TbankClient,
TbankLastPrice, type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
catalogScope, catalogScope,
type CatalogSource, type CatalogSource,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader'; import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
export type { CatalogPayload } from './catalog-source.loader';
import { catalogSearchLimit } from './catalog-selection'; import { catalogSearchLimit } from './catalog-selection';
import { publicInstrument } from './public-instrument'; import { previousClose, timestamp } from './instrument-market';
const MISSING_DATA_REVISION = 0; const MISSING_DATA_REVISION = 0;
const FIRST_QUEUE_INDEX = 0; const FIRST_QUEUE_INDEX = 0;
@@ -42,7 +58,7 @@ export class CatalogEnrichmentService {
private running = false; private running = false;
private readonly completions = new Map< private readonly completions = new Map<
string, string,
{ attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string } { done: Promise<void>; resolve: () => void; workId: string }
>(); >();
async ensureDetail( async ensureDetail(
@@ -76,7 +92,9 @@ export class CatalogEnrichmentService {
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
private readonly loader: CatalogSourceLoader, private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
private readonly candles: CandleRepository,
) {} ) {}
async readMany( async readMany(
@@ -91,7 +109,6 @@ export class CatalogEnrichmentService {
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string; to: string;
}, },
refreshBefore?: Date,
) { ) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
@@ -105,7 +122,6 @@ export class CatalogEnrichmentService {
false, false,
kinds, kinds,
range, range,
refreshBefore,
); );
} }
const work = inputs.length const work = inputs.length
@@ -158,7 +174,6 @@ export class CatalogEnrichmentService {
tickers?: Record<string, string>; tickers?: Record<string, string>;
to: string; to: string;
}, },
refreshBefore?: Date,
) { ) {
const inputs = sourceInputs(instrumentType, uids, kinds, range); const inputs = sourceInputs(instrumentType, uids, kinds, range);
const reservation = this.reservations.then(async () => { const reservation = this.reservations.then(async () => {
@@ -173,9 +188,7 @@ export class CatalogEnrichmentService {
); );
const policy = sourcePolicy(input.kind); const policy = sourcePolicy(input.kind);
const needsRefresh = force || !row?.fetchedAt || const needsRefresh = force || !row?.fetchedAt ||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) || (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
if (!needsRefresh) { if (!needsRefresh) {
continue; continue;
} }
@@ -195,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, { attempt, done, resolve, workId: id }); this.completions.set(attemptId, { workId: id, done, resolve });
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 });
@@ -217,14 +230,16 @@ 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',
uid: string, uid: string,
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false,
) { ) {
if (section === 'company') { if (section === 'company' && !observe) {
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => { const token = await this.access.onlineToken(userId);
const instrument = const instrument =
instrumentType === 'share' instrumentType === 'share'
? await this.tbank.getShare(token, uid) ? await this.tbank.getShare(token, uid)
@@ -236,8 +251,6 @@ export class CatalogEnrichmentService {
this.tbank.getFundamentals(token, instrument.assetUid), this.tbank.getFundamentals(token, instrument.assetUid),
]) ])
: [null, null]; : [null, null];
return { description, fundamentals, instrument };
});
const meta = investmentResourceMeta({ const meta = investmentResourceMeta({
resourceKey: `${instrumentType}/company/${uid}`, resourceKey: `${instrumentType}/company/${uid}`,
dataRevision: `online:${Date.now()}`, dataRevision: `online:${Date.now()}`,
@@ -253,7 +266,10 @@ export class CatalogEnrichmentService {
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(), observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
}; };
} }
const kinds: CatalogSource['kind'][] = [ const kinds: CatalogSource['kind'][] =
section === 'company'
? ['detail', 'description', 'fundamentals']
: [
...(instrumentType === 'bond' ...(instrumentType === 'bond'
? ['bond-current' as const] ? ['bond-current' as const]
: ['detail' as const]), : ['detail' as const]),
@@ -268,7 +284,7 @@ export class CatalogEnrichmentService {
userId, userId,
instrumentType, instrumentType,
[uid], [uid],
false, observe,
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
@@ -296,7 +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: tbankSourceTtlMs.quote, freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -314,6 +330,169 @@ export class CatalogEnrichmentService {
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
private async load(
input: CatalogSource,
token: string,
attempt: TbankSyncAttempt,
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
}
if (input.kind === 'trading-status') {
return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
};
}
if (input.kind === 'comparison') {
const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined },
]);
if (!prices.length) throw new Error('Price dependency missing');
const price = (prices[0].payload as CatalogPayload).price;
const priceAt = timestamp(price?.time);
if (!priceAt) return { candle: null };
const now = new Date();
const range = {
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
to: new Date(priceAt),
};
const saved = await this.candles.read(input.uid, range);
const gaps = candleGaps(range, saved.coverage, now);
for (const gap of gaps) {
const response = await this.tbank.getCandles(
token,
input.uid,
gap.from,
gap.to,
);
const rows = response.map(toSavedCandle);
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
}
if (
gaps.length &&
!(await this.candles.publish(
attempt,
{ uid: input.uid, ranges: gaps },
false,
))
) {
throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map(
(row) => {
const value = divide(
decimalFromString(row.close),
decimalFromString('1'),
quotationScale,
).value;
return {
time: {
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
},
close: {
units: String(value / quotationUnits),
nano: Number(value % quotationUnits),
},
isComplete: row.isComplete,
};
},
);
return { candle: previousClose(values, priceAt) };
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean);
if (
!instrument &&
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
) {
throw new Error('Instrument dependency missing');
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons'
? {
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
}
: {
dividends: await this.tbank.getDividends(
token,
input.uid,
from,
to,
),
};
}
if (input.kind === 'bond-events') {
const ticker = instrument?.ticker || input.ticker;
if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) };
}
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
)
: null,
};
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
};
}
throw new Error('Unsupported catalog source');
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion. // eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
private async drain() { private async drain() {
try { try {
@@ -351,7 +530,7 @@ export class CatalogEnrichmentService {
batch.map((item) => item.input.uid), batch.map((item) => item.input.uid),
); );
} else { } else {
payload = await this.loader.load(producer.input, token, producer.attempt); payload = await this.load(producer.input, token, producer.attempt);
} }
} catch (error) { } catch (error) {
failed = true; failed = true;
@@ -401,14 +580,6 @@ 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();
@@ -453,7 +624,58 @@ function sourceInputs(
})), })),
); );
} }
const previousCloseLookbackDays = 20;
const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
const payoutHistoryYears = 5;
const payoutFutureYears = 2;
export type CatalogPayload = {
bondEvents?: MoexBondEvent[];
candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
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 (
[ [
@@ -466,7 +688,6 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) { ) {
return 'quote'; return 'quote';
} }
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
return 'stable'; return 'stable';
} }
function producerKey(input: CatalogSource) { function producerKey(input: CatalogSource) {
@@ -475,3 +696,6 @@ function producerKey(input: CatalogSource) {
? `bond-metadata:${input.uid}` ? `bond-metadata:${input.uid}`
: catalogScope(input); : catalogScope(input);
} }
const quotationScale = 9;
const quotationUnits = 1_000_000_000n;
@@ -1,6 +1,6 @@
import type { TbankShare } from '#src/integrations/tbank/tbank.api'; import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol'; import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30; export const catalogSearchLimit = 30;
@@ -1,229 +0,0 @@
import { Injectable } from '@nestjs/common';
import {
decimalFromString,
divide,
} from '#src/common/decimal';
import {
MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND,
} from '#src/common/time';
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
import {
type TbankBond,
type TbankCandle,
TbankClient,
type TbankCoupon,
type TbankDividend,
type TbankFundamentals,
type TbankLastPrice,
type TbankShare,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository';
import {
CatalogDiscoveryRepository,
type CatalogSource,
} from '../infrastructure/catalog-discovery.repository';
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
import { previousClose, timestamp } from './instrument-market';
import { publicInstrument } from './public-instrument';
export type CatalogPayload = {
bondEvents?: MoexBondEvent[];
candle?: null | TbankCandle;
coupons?: TbankCoupon[];
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
description?: null | string;
dividends?: TbankDividend[];
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
fundamentals?: null | TbankFundamentals;
instrument?: null | TbankBond | TbankShare;
price?: null | TbankLastPrice;
tradingStatus?: null | string;
};
const previousCloseLookbackDays = 20;
const previousCloseLookbackMs =
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
const payoutHistoryYears = 5;
const payoutFutureYears = 2;
const quotationScale = 9;
const quotationUnits = 1_000_000_000n;
@Injectable()
export class CatalogSourceLoader {
constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly tbank: TbankClient,
private readonly moex: MoexClient,
private readonly ratings: NcrRatingsClient,
private readonly candles: CandleRepository,
) {}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
async load(
input: CatalogSource,
token: string,
attempt: TbankSyncAttempt,
): Promise<CatalogPayload> {
if (input.kind === 'detail' || input.kind === 'bond-current') {
const instrument =
input.instrumentType === 'share'
? await this.tbank.getShare(token, input.uid)
: await this.tbank.getBond(token, input.uid);
if (instrument && instrument.uid !== input.uid) {
throw new Error('Catalog UID mismatch');
}
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
if (instrument === undefined) throw new Error('Missing catalog response');
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
}
if (input.kind === 'trading-status') {
return {
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
};
}
if (input.kind === 'comparison') {
const prices = await this.repository.readSources([
{ ...input, kind: 'last-price', rangeKey: undefined },
]);
if (!prices.length) throw new Error('Price dependency missing');
const price = (prices[0].payload as CatalogPayload).price;
const priceAt = timestamp(price?.time);
if (!priceAt) return { candle: null };
const now = new Date();
const range = {
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
to: new Date(priceAt),
};
const saved = await this.candles.read(input.uid, range);
const gaps = candleGaps(range, saved.coverage, now);
for (const gap of gaps) {
const response = await this.tbank.getCandles(
token,
input.uid,
gap.from,
gap.to,
);
const rows = response.map(toSavedCandle);
if (!(await this.candles.stage(attempt, gap, rows, now))) {
throw new Error('Cancelled candle comparison');
}
}
if (
gaps.length &&
!(await this.candles.publish(
attempt,
{ uid: input.uid, ranges: gaps },
false,
))
) {
throw new Error('Cancelled candle comparison');
}
const values = (await this.candles.read(input.uid, range)).data.map(
(row) => {
const value = divide(
decimalFromString(row.close),
decimalFromString('1'),
quotationScale,
).value;
return {
time: {
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
},
close: {
units: String(value / quotationUnits),
nano: Number(value % quotationUnits),
},
isComplete: row.isComplete,
};
},
);
return { candle: previousClose(values, priceAt) };
}
const details = await this.repository.readSources([
{ ...input, kind: 'detail', rangeKey: undefined },
...(input.instrumentType === 'bond'
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
: []),
]);
const instrument = details
.map((row) => (row.payload as CatalogPayload).instrument)
.find(Boolean);
if (
!instrument &&
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
) {
throw new Error('Instrument dependency missing');
}
if (input.kind === 'description') {
return {
description: instrument?.assetUid
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'fundamentals') {
return {
fundamentals: instrument?.assetUid
? await this.tbank.getFundamentals(token, instrument?.assetUid)
: null,
};
}
if (input.kind === 'coupons' || input.kind === 'dividends') {
const year = Number(input.rangeKey);
const [from, to] = input.rangeKey?.startsWith('[')
? (JSON.parse(input.rangeKey) as [string, string])
: [
`${year - payoutHistoryYears}-01-01`,
`${year + payoutFutureYears}-01-01`,
];
return input.kind === 'coupons'
? {
coupons: await this.tbank.getBondCoupons(
token,
input.uid,
from,
to,
),
}
: {
dividends: await this.tbank.getDividends(
token,
input.uid,
from,
to,
),
};
}
if (input.kind === 'bond-events') {
const ticker = instrument?.ticker || input.ticker;
if (!ticker) throw new Error('Bond ticker missing');
return { bondEvents: await this.moex.bondEvents(ticker) };
}
if (input.kind === 'yield') {
return {
effectiveYield:
instrument?.ticker && instrument.isin && instrument.classCode
? await this.moex.bondYield(
instrument.ticker,
instrument.isin,
instrument.classCode,
)
: null,
};
}
if (input.kind === 'rating') {
return {
creditRating: instrument?.isin
? await this.ratings.rating(instrument.isin)
: null,
};
}
throw new Error('Unsupported catalog source');
}
}
@@ -1,9 +1,9 @@
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '#src/integrations/tbank/tbank.api'; } from '../../../../integrations/tbank/tbank.api';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
@@ -22,16 +22,14 @@ const row = (
isin: null, isin: null,
ticker: null, ticker: null,
logoName: null, logoName: null,
sector: null,
issuer: null,
}); });
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({ const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions, positions,
cash: { currency: 'RUB', value: '0' }, cashBalances: [],
totals: { currency: 'RUB', value: '0' }, totalsByCurrency: [],
isStale: false, isStale: false,
lastSuccessfulAt: '2026-09-10T00:00:00Z', lastSuccessfulAt: '2026-09-10T00:00:00Z',
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] }, structure: { currencies: [], unvaluedCategories: [] },
}); });
describe('stock position', () => { describe('stock position', () => {
@@ -64,10 +62,10 @@ describe('stock position', () => {
expect(result.averagePurchasePrice?.value).toBe('0.1'); expect(result.averagePurchasePrice?.value).toBe('0.1');
}, },
); );
it('does not treat unknown values as zero', () => { it('does not sum currencies or treat unknown values as zero', () => {
const second = { const second = {
...row(), ...row(),
currentValue: null, currentValue: money('2', 'USD'),
averagePurchasePrice: null, averagePurchasePrice: null,
absoluteReturn: null, absoluteReturn: null,
}; };
@@ -4,9 +4,8 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '#src/common/decimal'; } from '../../../../common/decimal';
import { MoneyDto } from '#src/common/money.dto'; import { MoneyDto } from '../../../../common/money.dto';
import { import {
PortfolioDto, PortfolioDto,
PortfolioPositionDto, PortfolioPositionDto,
@@ -46,7 +45,7 @@ export function instrumentPosition(
averagePurchasePrice: averagePurchasePrice:
positive && totalCost positive && totalCost
? { ? {
currency: 'RUB', currency: totalCost.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
decimalFromString(totalCost.value), decimalFromString(totalCost.value),
@@ -83,12 +82,17 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
: null; : null;
} }
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null { function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null; const currency = values[0]?.currency;
const present = values; if (
!currency ||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
) {
return null;
}
return { return {
currency: 'RUB', currency,
value: formatDecimal( value: formatDecimal(
present.reduce( values.reduce(
(sum, value) => add(sum, decimalFromString(value.value)), (sum, value) => add(sum, decimalFromString(value.value)),
decimalFromString('0'), decimalFromString('0'),
), ),
@@ -102,6 +106,7 @@ function percent(
if ( if (
!base || !base ||
!result || !result ||
base.currency !== result.currency ||
decimalFromString(base.value).value <= ZERO_AMOUNT decimalFromString(base.value).value <= ZERO_AMOUNT
) { ) {
return null; return null;
@@ -1,64 +0,0 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument';
const share = {
uid: 'share-uid',
figi: 'FIGI1',
assetUid: 'asset-1',
name: 'Share',
ticker: 'SHAR',
isin: 'US000',
classCode: 'TQBR',
realExchange: 'REAL_EXCHANGE_MOEX',
apiTradeAvailableFlag: true,
currency: 'rub',
shareType: 'SHARE_TYPE_COMMON',
sector: 'Finance',
lot: 1,
brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' },
first_1dayCandleDate: '2020-01-01T00:00:00Z',
privateField: 'must-not-leak',
} as unknown as TbankShare;
const bond = {
uid: 'bond-uid',
figi: 'FIGI2',
assetUid: 'asset-2',
name: 'Bond',
ticker: 'BOND',
classCode: 'TQOB',
currency: 'rub',
lot: 1,
nominal: { value: '1000', currency: 'rub' },
initialNominal: { value: '1000', currency: 'rub' },
aciValue: { value: '10', currency: 'rub' },
maturityDate: '2030-01-01T00:00:00Z',
couponQuantityPerYear: 2,
bondType: 'BOND_TYPE_EXPIRING',
brand: null,
privateField: 'must-not-leak',
} as unknown as TbankBond;
describe('publicInstrument projection', () => {
it('keeps approved share fields, trims brand, drops private extras', () => {
const result = publicInstrument(share);
expect(result.uid).toBe('share-uid');
expect(result.shareType).toBe('SHARE_TYPE_COMMON');
expect(result.brand).toEqual({ logoName: 'logo.png' });
expect(result).not.toHaveProperty('privateField');
expect(result).not.toHaveProperty('brand.title');
});
it('projects bonds with missing optional fields and null brand', () => {
const result = publicInstrument(bond);
expect(result.nominal).toEqual({ value: '1000', currency: 'rub' });
expect(result.shareType).toBeUndefined();
expect(result.brand).toBeUndefined();
expect(result.isin).toBeUndefined();
expect(result).not.toHaveProperty('privateField');
});
});
@@ -1,38 +0,0 @@
import type {
TbankBond,
TbankShare,
} from '#src/integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '#src/integrations/tbank/tbank.api'; import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
@@ -15,8 +14,7 @@ import {
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0; const MIN_LOT_EXCLUSIVE = 0;
@@ -27,10 +25,12 @@ export class BondCatalogService {
async search( async search(
userId: string, userId: string,
query: string, query: string,
observe = false,
): Promise<BondSearchResourceDto> { ): Promise<BondSearchResourceDto> {
const result = await this.discovery.search( const result = await this.discovery.search(
userId, userId,
{ instrumentType: 'bond', query }, { instrumentType: 'bond', query },
observe,
); );
return { return {
...result, ...result,
@@ -58,8 +58,9 @@ export class BondCatalogService {
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.detail(userId, 'bond', uid); const result = await this.discovery.detail(userId, 'bond', uid, observe);
return { return {
...result, ...result,
data: result.data data: result.data
@@ -71,11 +72,13 @@ export class BondCatalogService {
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.byTicker( const result = await this.discovery.byTicker(
userId, userId,
'bond', 'bond',
ticker, ticker,
observe,
); );
return { return {
...result, ...result,
@@ -5,20 +5,19 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '#src/common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol'; import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
type TbankCoupon, type TbankCoupon,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
} from '#src/integrations/tbank/tbank.api'; } from '../../../../../integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
PortfolioService, PortfolioService,
} from '#src/modules/investments/portfolio/portfolio.api'; } from '../../../portfolio/portfolio.api';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
type CatalogPayload, type CatalogPayload,
@@ -55,12 +54,14 @@ export class BondIncomeService {
async metrics( async metrics(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondMetricsResourceDto> { ): Promise<BondMetricsResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'metrics', 'metrics',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -81,6 +82,7 @@ export class BondIncomeService {
async coupons( async coupons(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondCouponsResourceDto> { ): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
@@ -91,7 +93,7 @@ export class BondIncomeService {
userId, userId,
'bond', 'bond',
[uid], [uid],
false, observe,
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
@@ -178,7 +180,7 @@ export function bondCoupons(
recordDate && recordDate &&
recordDate >= today recordDate >= today
? { ? {
currency: 'RUB', currency: perBond.currency,
value: formatDecimal( value: formatDecimal(
multiply(quantity, decimalFromString(perBond.value)), multiply(quantity, decimalFromString(perBond.value)),
), ),
@@ -227,6 +229,7 @@ export function bondMetrics(
coupon && coupon &&
couponPeriodDays && couponPeriodDays &&
clean && clean &&
coupon.currency === clean.currency &&
decimalFromString(coupon.value).value >= ZERO_AMOUNT && decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
decimalFromString(clean.value).value > ZERO_AMOUNT decimalFromString(clean.value).value > ZERO_AMOUNT
? formatDecimal( ? formatDecimal(
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '#src/integrations/tbank/tbank.api'; import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
import { import {
add, add,
@@ -9,10 +9,9 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
} from '#src/common/decimal'; } from '../../../../../common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
import { decimal } from '#src/integrations/tbank/decimal'; import { decimal } from '../../../../../integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -39,12 +38,14 @@ export class BondMarketService {
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondQuoteResourceDto> { ): Promise<BondQuoteResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'quote', 'quote',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -58,10 +59,12 @@ export class BondMarketService {
userId: string, userId: string,
uid: string, uid: string,
period: BondCandlesQueryDto['period'] = 'month', period: BondCandlesQueryDto['period'] = 'month',
observe = false,
) { ) {
return this.history.read( return this.history.read(
userId, userId,
{ uid, instrumentType: 'bond', period }, { uid, instrumentType: 'bond', period },
observe,
); );
} }
} }
@@ -82,7 +85,7 @@ export function bondQuote(
nominal && nominal &&
decimalFromString(nominal.value).value > ZERO_AMOUNT decimalFromString(nominal.value).value > ZERO_AMOUNT
? { ? {
currency: 'RUB', currency: nominal.currency,
value: formatDecimal( value: formatDecimal(
divide( divide(
multiply( multiply(
@@ -96,9 +99,9 @@ export function bondQuote(
} }
: null; : null;
const dirtyPrice = const dirtyPrice =
cleanPrice && aci cleanPrice && aci && aci.currency === cleanPrice.currency
? { ? {
currency: 'RUB', currency: cleanPrice.currency,
value: formatDecimal( value: formatDecimal(
add( add(
decimalFromString(cleanPrice.value), decimalFromString(cleanPrice.value),
@@ -1,9 +1,9 @@
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api'; import type { PortfolioDto } from '../../../portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
jest.mock('#src/prisma/prisma.service', () => ({ jest.mock('../../../../../prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1 +1,10 @@
export { rubMoney as money } from '#src/integrations/tbank/decimal'; import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase();
return value && currency
? { currency, value: formatDecimal(decimal(value)) }
: null;
}
@@ -11,9 +11,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service'; import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service'; import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service'; import { BondMarketService } from '../application/bond-market.service';
@@ -47,8 +47,9 @@ export class BondController {
metrics( metrics(
@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.metrics(request.user.sub, uid); return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
} }
@Get() @Get()
@@ -60,6 +61,7 @@ export class BondController {
return this.bonds.search( return this.bonds.search(
request.user.sub, request.user.sub,
query.query, query.query,
query.sync === 'observe',
); );
} }
@@ -68,10 +70,12 @@ export class BondController {
getByTicker( getByTicker(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto, @Param() params: BondTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.bonds.getByTicker( return this.bonds.getByTicker(
request.user.sub, request.user.sub,
params.ticker, params.ticker,
query.sync === 'observe',
); );
} }
@@ -80,8 +84,9 @@ export class BondController {
get( get(
@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.bonds.get(request.user.sub, uid); return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/quote') @Get(':uid/quote')
@@ -89,8 +94,9 @@ export class BondController {
quote( quote(
@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.market.quote(request.user.sub, uid); return this.market.quote(request.user.sub, uid, query.sync === 'observe');
} }
@Get(':uid/candles') @Get(':uid/candles')
@@ -104,6 +110,7 @@ export class BondController {
request.user.sub, request.user.sub,
uid, uid,
query.period, query.period,
query.sync === 'observe',
); );
} }
@@ -121,7 +128,8 @@ export class BondController {
coupons( coupons(
@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.coupons(request.user.sub, uid); return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
} }
} }
@@ -1,8 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '#src/common/money.dto'; import { MoneyDto } from '../../../../../common/money.dto';
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto'; import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
import { import {
StockCandlesDto, StockCandlesDto,
StockCandlesQueryDto, StockCandlesQueryDto,
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_DAY } from '#src/common/time'; import { MILLISECONDS_PER_DAY } from '../../../../common/time';
const FIRST_RANGE_END_INDEX = 1; const FIRST_RANGE_END_INDEX = 1;
@@ -1,19 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import type { Prisma } from '#generated/prisma/client'; import type { Prisma } from '../../../../../generated/prisma/client';
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import {
claimSyncWork,
lockConnectionGeneration,
} from '#src/integrations/tbank/tbank-sync-claim';
import {
isJoinableRunningAttempt,
type TbankSyncAttempt,
} from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '#src/prisma/to-json';
import { PrismaService } from '../../../../prisma/prisma.service';
import { import {
type CandleRange, type CandleRange,
confirmedCandleCoverage, confirmedCandleCoverage,
@@ -71,30 +62,34 @@ export class CandleRepository {
priority = 1, priority = 1,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const current = await lockConnectionGeneration(tx, owner); const current = await tx.tbankConnection.updateMany({
if (!current) return null; where: { ...owner, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const scope = { ...owner, scopeKey }; const scope = { ...owner, scopeKey };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
const joinable = isJoinableRunningAttempt(previous, runId, active) if (
? previous previous?.state === 'running' &&
: null; previous.processRunId === runId &&
if (joinable) { active.has(previous.attemptId)
const previousIntent = joinable.intent as Prisma.JsonObject; ) {
const previousIntent = previous.intent as Prisma.JsonObject;
const previousNow = const previousNow =
typeof previousIntent.now === 'string' ? previousIntent.now : ''; typeof previousIntent.now === 'string' ? previousIntent.now : '';
const nextPriority = Math.min(joinable.priority, priority); const nextPriority = Math.min(previous.priority, priority);
if ( if (
nextPriority === joinable.priority && nextPriority === previous.priority &&
(!intent.force || (!intent.force ||
(previousIntent.force === true && (previousIntent.force === true &&
(!intent.now || intent.now <= previousNow))) (!intent.now || intent.now <= previousNow)))
) { ) {
return { work: joinable, launch: false }; return { work: previous, launch: false };
} }
const work = await tx.tbankSyncWork.update({ const work = await tx.tbankSyncWork.update({
where: { id: joinable.id }, where: { id: previous.id },
data: { data: {
priority: nextPriority, priority: nextPriority,
intent: { intent: {
@@ -120,7 +115,7 @@ export class CandleRepository {
processRunId: runId, processRunId: runId,
state: 'running', state: 'running',
priority, priority,
intent: toJson(intent), intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
progress: {}, progress: {},
errorCode: null, errorCode: null,
}; };
@@ -168,17 +163,24 @@ export class CandleRepository {
).values(), ).values(),
]; ];
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const valid = await claimSyncWork(tx, attempt, { const valid = await tx.tbankSyncWork.updateMany({
progress: { lastWindow: key }, where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { progress: { lastWindow: key } },
}); });
if (!valid) return false; if (!valid.count) return false;
const identity = { const identity = {
workId: attempt.id, workId: attempt.id,
attemptId: attempt.attemptId, attemptId: attempt.attemptId,
sourceKey: 'candles', sourceKey: 'candles',
itemKey: key, itemKey: key,
}; };
const payload = toJson({ range, candles: rows, fetchedAt }); const payload = JSON.parse(
JSON.stringify({ range, candles: rows, fetchedAt }),
) as Prisma.InputJsonObject;
await tx.tbankSyncStage.upsert({ await tx.tbankSyncStage.upsert({
where: { workId_attemptId_sourceKey_itemKey: identity }, where: { workId_attemptId_sourceKey_itemKey: identity },
create: { ...identity, payload }, create: { ...identity, payload },
@@ -194,11 +196,18 @@ export class CandleRepository {
completeWork = true, completeWork = true,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await claimSyncWork(tx, attempt, { const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: {
...(completeWork ? { state: 'complete' } : {}), ...(completeWork ? { state: 'complete' } : {}),
errorCode: null, errorCode: null,
},
}); });
if (!claimed) return false; if (!claimed.count) return false;
const staged = await tx.tbankSyncStage.findMany({ const staged = await tx.tbankSyncStage.findMany({
where: { where: {
workId: attempt.id, workId: attempt.id,
@@ -302,17 +311,28 @@ export class CandleRepository {
}, },
}); });
if (!origin) return false; if (!origin) return false;
const result = await claimSyncWork(tx, attempt, { const result = await tx.tbankSyncWork.updateMany({
state: 'complete', where: {
errorCode: null, ...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', errorCode: null },
}); });
return result > NO_UPDATED_ROWS; return result.count > NO_UPDATED_ROWS;
}); });
} }
fail(attempt: TbankSyncAttempt, errorCode: string) { fail(attempt: TbankSyncAttempt, errorCode: string) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
await claimSyncWork(tx, attempt, { state: 'failed', errorCode }); await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'failed', errorCode },
});
await tx.tbankSyncStage.deleteMany({ await tx.tbankSyncStage.deleteMany({
where: { workId: attempt.id, attemptId: attempt.attemptId }, where: { workId: attempt.id, attemptId: attempt.attemptId },
}); });
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; import type { Prisma } from '../../../../../generated/prisma/client';
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim'; import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '#src/prisma/prisma.service'; import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { toJson } from '#src/prisma/to-json'; import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1; const REVISION_INCREMENT = 1;
@@ -159,19 +159,27 @@ export class CatalogDiscoveryRepository {
initiator: TbankSyncAttempt = attempt, initiator: TbankSyncAttempt = attempt,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const origin = await claimSyncWork( const origin = await tx.tbankSyncWork.updateMany({
tx, where: {
initiator, ...initiator,
{ progress: {} }, state: { in: ['running', 'complete'] },
{ state: { in: ['running', 'complete'] } }, connection: { invalidToken: false },
); },
if (!origin) return false; data: { progress: {} },
const claimed = await claimSyncWork(tx, attempt, {
state: 'complete',
progress: {},
}); });
if (!claimed) return false; if (!origin.count) return false;
const payload = toJson(value); const claimed = await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
});
if (!claimed.count) return false;
const payload = JSON.parse(
JSON.stringify(value),
) as Prisma.InputJsonObject;
const identity = { provider, uid: input.uid }; const identity = { provider, uid: input.uid };
await tx.tbankInstrument.upsert({ await tx.tbankInstrument.upsert({
where: { provider_uid: identity }, where: { provider_uid: identity },
@@ -194,10 +202,12 @@ export class CatalogDiscoveryRepository {
const previous = await tx.tbankInstrument.findUniqueOrThrow({ const previous = await tx.tbankInstrument.findUniqueOrThrow({
where: { provider_uid: identity }, where: { provider_uid: identity },
}); });
const metadata = toJson({ const metadata = JSON.parse(
JSON.stringify({
...instrument, ...instrument,
instrumentType: input.instrumentType, instrumentType: input.instrumentType,
}); }),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.update({ await tx.tbankInstrument.update({
where: { provider_uid: identity }, where: { provider_uid: identity },
data: { data: {
@@ -274,13 +284,20 @@ export class CatalogDiscoveryRepository {
active: ReadonlySet<string>, active: ReadonlySet<string>,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const current = await lockConnectionGeneration(tx, owner); const current = await tx.tbankConnection.updateMany({
if (!current) return null; where: { ...owner, invalidToken: false },
data: { membershipRevision: { increment: 0 } },
});
if (!current.count) return null;
const scope = { ...owner, scopeKey: catalogScope(input) }; const scope = { ...owner, scopeKey: catalogScope(input) };
const previous = await tx.tbankSyncWork.findUnique({ const previous = await tx.tbankSyncWork.findUnique({
where: { userId_revision_scopeKey: scope }, where: { userId_revision_scopeKey: scope },
}); });
if (isJoinableRunningAttempt(previous, runId, active)) { if (
previous?.state === 'running' &&
previous.processRunId === runId &&
active.has(previous.attemptId)
) {
return { work: previous, launch: false }; return { work: previous, launch: false };
} }
const data = { const data = {
@@ -328,15 +345,20 @@ export class CatalogDiscoveryRepository {
now: Date, now: Date,
) { ) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
const claimed = await claimSyncWork(tx, attempt, { const claimed = await tx.tbankSyncWork.updateMany({
state: 'complete', where: {
progress: {}, ...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: { state: 'complete', progress: {} },
}); });
if (!claimed) return false; if (!claimed.count) return false;
for (const instrument of instruments) { for (const instrument of instruments) {
// Discovery can create a minimal identity, but cannot overwrite verified metadata. // Discovery can create a minimal identity, but cannot overwrite verified metadata.
const identity = { provider, uid: instrument.uid }; const identity = { provider, uid: instrument.uid };
const payload = toJson({ const payload = JSON.parse(
JSON.stringify({
uid: instrument.uid, uid: instrument.uid,
figi: instrument.figi, figi: instrument.figi,
ticker: instrument.ticker, ticker: instrument.ticker,
@@ -349,7 +371,8 @@ export class CatalogDiscoveryRepository {
brand: instrument.brand brand: instrument.brand
? { logoName: instrument.brand.logoName } ? { logoName: instrument.brand.logoName }
: undefined, : undefined,
}); }),
) as Prisma.InputJsonObject;
await tx.tbankInstrument.upsert({ await tx.tbankInstrument.upsert({
where: { provider_uid: identity }, where: { provider_uid: identity },
update: {}, update: {},
@@ -442,7 +465,6 @@ export class CatalogDiscoveryRepository {
finish(attempt: TbankSyncAttempt, success: boolean) { finish(attempt: TbankSyncAttempt, success: boolean) {
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
// Fails sibling attempts whose connection was invalidated mid-flight.
await tx.tbankSyncWork.updateMany({ await tx.tbankSyncWork.updateMany({
where: { where: {
...attempt, ...attempt,
@@ -451,9 +473,16 @@ export class CatalogDiscoveryRepository {
}, },
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
}); });
await claimSyncWork(tx, attempt, { await tx.tbankSyncWork.updateMany({
where: {
...attempt,
state: 'running',
connection: { invalidToken: false },
},
data: {
state: success ? 'complete' : 'failed', state: success ? 'complete' : 'failed',
errorCode: success ? null : TbankErrorCode.Unavailable, errorCode: success ? null : TbankErrorCode.Unavailable,
},
}); });
}); });
} }

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