Compare commits
93
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84433ee207 | ||
|
|
d792baaf59 | ||
|
|
83b515a453 | ||
|
|
07cfd08df9 | ||
|
|
e3ecb8c15e | ||
|
|
61c55256e7 | ||
|
|
cea77f6d56 | ||
|
|
b3d4801cfe | ||
|
|
b3ca11d141 | ||
|
|
efac7201e0 | ||
|
|
85a71967fa | ||
|
|
cd78b43783 | ||
|
|
deda7ffc2c | ||
|
|
5d31b3e6dd | ||
|
|
c5ccfeadea | ||
|
|
02e0d560c8 | ||
|
|
fa0a6e5603 | ||
|
|
bade5fae1c | ||
|
|
1a5de9bebd | ||
|
|
aeb4647fda | ||
|
|
169bee1da5 | ||
|
|
554410a271 | ||
|
|
a80bbbf1e2 | ||
|
|
a18d9dbdc8 | ||
|
|
17432dfee3 | ||
|
|
5e84e1e5f1 | ||
|
|
e350146749 | ||
|
|
99b137ed0b | ||
|
|
d37fa2c903 | ||
|
|
5295945ab2 | ||
|
|
22668da3fa | ||
|
|
2325b63883 | ||
|
|
f2843e30bd | ||
|
|
97fc41fa29 | ||
|
|
3dcd1a69a6 | ||
|
|
8503429b46 | ||
|
|
0dff4b4377 | ||
|
|
a250142a86 | ||
|
|
0a0e70b273 | ||
|
|
449cfcd6c5 | ||
|
|
966a1f793a | ||
|
|
d1f6969dc7 | ||
|
|
b20080ac85 | ||
|
|
32622c1dad | ||
|
|
4f485ab0e9 | ||
|
|
8a6f60d965 | ||
|
|
e03fad622b | ||
|
|
774e58ef80 | ||
|
|
702302126b | ||
|
|
ec6d9a2639 | ||
|
|
7bc7d8ddec | ||
|
|
9e4f9d9cab | ||
|
|
a93f2d55e7 | ||
|
|
96d4de4195 | ||
|
|
c789e33c41 | ||
|
|
2ad3517d87 | ||
|
|
0d7839cf79 | ||
|
|
6975853656 | ||
|
|
14bcb9da5f | ||
|
|
b90cedd580 | ||
|
|
da2e1d0781 | ||
|
|
bbb5afff84 | ||
|
|
285dd08472 | ||
|
|
1033d2114e | ||
|
|
203cc72cb2 | ||
|
|
9b7e5e1215 | ||
|
|
31b0e15579 | ||
|
|
3d50db7903 | ||
|
|
8d5569a3c7 | ||
|
|
90486d4237 | ||
|
|
52fa3a36ba | ||
|
|
b9b76bab91 | ||
|
|
fa57a201fd | ||
|
|
96a442253e | ||
|
|
c5248dbefc | ||
|
|
c7d060023b | ||
|
|
a31e8d2c4d | ||
|
|
2f7c15205f | ||
|
|
d7895f287e | ||
|
|
bcd5cb3dd0 | ||
|
|
0ad84ff4f0 | ||
|
|
7d10ef8adf | ||
|
|
bc631e72de | ||
|
|
947ffefa9b | ||
|
|
db375cbee7 | ||
|
|
a46a4112cb | ||
|
|
966b2a5836 | ||
|
|
323b83b582 | ||
|
|
5d857de7a5 | ||
|
|
2facd8675c | ||
|
|
4870d49503 | ||
|
|
85858a160f | ||
|
|
0057925888 |
+6
-6
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
|
||||
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
||||
чистые расчёты portfolio/banking остаются у domain owner.
|
||||
|
||||
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
|
||||
через concrete repositories; существующие связные CRUD/transaction services
|
||||
сохраняют прямые queries по [data convention](conventions/data.md).
|
||||
PrismaService владеет DB lifecycle. Application services/use cases получают
|
||||
данные через concrete repositories; запросы и CAS живут в infrastructure
|
||||
по [data convention](conventions/data.md).
|
||||
Money — currency + decimal string; расчёты используют точные общие helpers.
|
||||
|
||||
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
||||
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
|
||||
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||
может использовать application types/input и domain rules; новые interfaces
|
||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
||||
по [Data](conventions/data.md); use case получает данные через repository и сам
|
||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
||||
infrastructure. Application не строит Prisma queries: use case и связный CRUD
|
||||
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
|
||||
на каждую таблицу не требуется.
|
||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||
|
||||
|
||||
+10
-10
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
|
||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||
не становятся domain entities: расчёт получает только нужные ему значения.
|
||||
|
||||
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
|
||||
services (auth, lifecycle подключения, изменения карт/операций/категорий)
|
||||
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
|
||||
лишь набора pass-through методов. Новая самостоятельная read/write capability
|
||||
получает repository method; самостоятельный use case не выполняет Prisma queries.
|
||||
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
|
||||
существующих banking services. Такой callback с Prisma transaction client —
|
||||
локальная техническая граница этих services, не API для domain или use cases.
|
||||
Application-код не импортирует PrismaService и не строит Prisma queries.
|
||||
Репозиторий владеет запросом и CAS-проверкой результата, а application —
|
||||
правилами и формированием ответа. Типы Prisma допустимы только в type-only
|
||||
импортах для существующей транзакционной границы. Не создавай обёртку
|
||||
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
|
||||
сохраняет retry/transaction boundary для банковских записей; callback с
|
||||
transaction client — локальная техническая граница, не API для domain.
|
||||
|
||||
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
||||
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
|
||||
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
|
||||
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||
|
||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||
|
||||
@@ -7,11 +7,19 @@
|
||||
checkout. Внешний API задаётся предоставленным публичным контрактом;
|
||||
исходники или инструкции его реализации не являются зависимостью разработки.
|
||||
При нехватке требования сообщи, какое решение не определено, до его реализации.
|
||||
При конфликте с общим шаблоном следуй местным правилам и явному запросу
|
||||
пользователя. Выбирай реализацию самостоятельно, но изменение финансовой
|
||||
логики, публичного контракта или утверждённой семантики возвращай владельцу
|
||||
на согласование до зависимой работы.
|
||||
|
||||
Начни с результата и affected areas. До edits проследи flow/invariants, найди
|
||||
owner и 1–3 примера. Код показывает механизм; случайный аналог не меняет контракт.
|
||||
Работай напрямую; OpenSpec/SDD lifecycle — только по явному запросу.
|
||||
Перед Node-командами активируй местную `.nvmrc`: `nvm use --silent`.
|
||||
Выбери lightweight или SDD по Engineering Workflow из AGENTS; OpenSpec lifecycle
|
||||
начинай после явного решения Change Owner.
|
||||
Перед Node-командами активируй местную `.nvmrc`: если `nvm` не загружен в
|
||||
non-interactive shell, загрузи установленный `$HOME/.nvm/nvm.sh`; затем
|
||||
выполни `nvm use --silent` из backend. Если `nvm.sh` недоступен, остановись,
|
||||
не используй системный Node.
|
||||
Сохраняй чужие staged/unstaged/untracked изменения и read-only архивы.
|
||||
|
||||
## Change scope
|
||||
@@ -57,19 +65,33 @@ Peer-конфликт решай совместимыми версиями бе
|
||||
|
||||
## Проверки и завершение
|
||||
|
||||
Code/dependency change: `npm ci`, полный `npm run verify` и дополнительные
|
||||
risk checks из местной testing convention. Targeted tests ускоряют итерацию,
|
||||
но не заменяют финальный gate. `lint:fix`/format — edits, не proof.
|
||||
Markdown-only: проверь новые/изменённые тексты, локальные ссылки/якоря, маршруты
|
||||
чтения и `git diff --check`; application build ради текста не нужен.
|
||||
Повтори routing по staged/unstaged/new files. Запиши краткие команды, результаты
|
||||
и ограничения. Зелёный lint не заменяет semantic/security review.
|
||||
| Когда | Что проверить |
|
||||
| --- | --- |
|
||||
| Окружение новое, изменились manifest/lockfile или Node, установка сомнительна | `npm ci` до Node-проверок; одна правка кода переустановки не требует. |
|
||||
| Итерация | Адресные тесты, typecheck и lint по затронутому поведению и риску. При изменении поведения добавь/обнови содержательную регрессию, если существующая проверка не ловит её; для косметики не создавай искусственный тест. |
|
||||
| Итоговый code/dependency diff | Перед объявлением готовности и передачей к интеграции выполни один полный `npm run verify` на итоговых входах. Дополнительные risk checks из testing convention и явно утверждённые task checks обязательны; CI проверяет независимо. |
|
||||
| Только Markdown | Проверь тексты, ссылки/якоря, маршруты чтения и `git diff --check`; application build не нужен. При изменении verification-контура проверь его работу. |
|
||||
|
||||
Повтори routing по staged/unstaged/new files. В отчёте по задаче/PR запиши
|
||||
проверенные входы, команду, exit code, результат и ограничения. Повторяй
|
||||
проверку при изменении значимых входов (включая config, dependencies и
|
||||
generated artifacts), сомнении в evidence или явном запросе пользователя.
|
||||
`lint:fix`/format — edits, не proof; lint не заменяет semantic/security review.
|
||||
|
||||
## Git и релизы
|
||||
|
||||
Работа — в `codex/<task>` от актуального `develop`, затем PR в `develop`.
|
||||
Работа — в ветке `<type>/<issue-number>-<slug>` от актуального `develop`,
|
||||
затем PR в `develop`; для cross-repo ссылки укажи owner/repo#number.
|
||||
Имя ветки и связь PR с реальной Issue проверяются вручную: commitlint
|
||||
проверяет синтаксис commit/PR title, но не существование и соответствие Issue.
|
||||
Сохраняй чужие изменения; перед commit просмотри staged paths и diff.
|
||||
Commits и PR titles — Conventional Commits; допустим `spec:`. Каждый commit
|
||||
содержит одну связанную задачу. Root другого checkout не управляет этим repo.
|
||||
Commits и PR titles — Conventional Commits с parser-recognized Issue reference; допустим
|
||||
`spec:`. Каждый commit содержит одну задачу. Исключения release и правила
|
||||
ссылки — в Engineering Workflow из AGENTS. Root другого checkout не управляет этим repo.
|
||||
Прямой push в `main`, force-push и перенос опубликованных тегов не входят в
|
||||
обычную работу. Перед release/hotfix прочитай местный README, раздел «Релизы».
|
||||
обычную работу. PR сливается squash или rebase-merge; merge-коммит не
|
||||
используется. `main` обновляется только fast-forward из `develop`, поэтому
|
||||
`origin/main` всегда предок `origin/develop`:
|
||||
`git merge-base --is-ancestor origin/main origin/develop`. Срочный fix —
|
||||
обычная ветка `fix/*` от `develop`; пути hotfix от `main` нет.
|
||||
Перед релизом прочитай местный README, раздел «Релизы».
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Bug Report
|
||||
about: Неверное поведение
|
||||
---
|
||||
## Проблема и шаги воспроизведения
|
||||
|
||||
## Факт / ожидание
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Окружение / ссылки (если применимо)
|
||||
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: Engineering Task
|
||||
about: CI, тесты, документация, процесс и прочее
|
||||
---
|
||||
## Контекст и результат
|
||||
|
||||
## Scope / связанные Issues (если есть)
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Feature Request
|
||||
about: Новое наблюдаемое поведение
|
||||
---
|
||||
## Зачем и какой результат
|
||||
|
||||
## Ожидаемое поведение
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / связанные Issues (если есть)
|
||||
@@ -0,0 +1,12 @@
|
||||
---
|
||||
name: Refactoring / Technical Debt
|
||||
about: Улучшение без нового поведения
|
||||
---
|
||||
## Проблема и результат
|
||||
|
||||
## Сохраняемые инварианты / границы
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ]
|
||||
|
||||
## Зависимости / ссылки (если есть)
|
||||
@@ -0,0 +1,11 @@
|
||||
## Summary
|
||||
|
||||
## Linked Issue
|
||||
Refs
|
||||
|
||||
## Changes
|
||||
|
||||
## Verification
|
||||
- Команда / результат / ограничения:
|
||||
|
||||
## Breaking Changes (если есть)
|
||||
@@ -28,9 +28,8 @@ jobs:
|
||||
docker version
|
||||
docker buildx version
|
||||
- run: npm ci
|
||||
- run: npx --no-install prisma generate
|
||||
- run: npm run verify
|
||||
- run: npm run test:e2e
|
||||
- run: node test/sqlite.cjs --all
|
||||
- name: Login with dedicated Nexus writer
|
||||
env:
|
||||
REGISTRY_USERNAME: ${{ secrets.NEXUS_USERNAME }}
|
||||
|
||||
@@ -34,12 +34,17 @@ jobs:
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
printf '%s\n' "$PR_TITLE" | npm run commitlint -- --strict
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
if [[ "$PR_TITLE" =~ ^chore\(release\):\ [0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
npm run commitlint -- --strict --from "$HEAD_SHA^" --to "$HEAD_SHA"
|
||||
else
|
||||
npm run commitlint -- --strict --from "$BASE_SHA" --to "$HEAD_SHA"
|
||||
fi
|
||||
|
||||
security:
|
||||
needs: commits
|
||||
if: >-
|
||||
!cancelled() &&
|
||||
gitea.event.action != 'edited' &&
|
||||
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
|
||||
timeout-minutes: 15
|
||||
runs-on: ubuntu-latest
|
||||
@@ -67,7 +72,8 @@ jobs:
|
||||
verify:
|
||||
needs: [commits, security]
|
||||
if: >-
|
||||
!cancelled() && needs.security.result == 'success' &&
|
||||
!cancelled() && gitea.event.action != 'edited' &&
|
||||
needs.security.result == 'success' &&
|
||||
gitea.event_name != 'schedule'
|
||||
timeout-minutes: 45
|
||||
runs-on: ubuntu-latest
|
||||
@@ -81,12 +87,10 @@ jobs:
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- run: npm ci
|
||||
- name: Generate Prisma client before typechecks
|
||||
run: npx --no-install prisma generate
|
||||
- name: Run all backend gates
|
||||
run: npm run verify
|
||||
- name: Run isolated SQLite and runtime OpenAPI suites
|
||||
run: npm run test:e2e
|
||||
run: node test/sqlite.cjs --all
|
||||
- name: Analyze develop with SonarQube
|
||||
if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/develop'
|
||||
# v2.3.0: same Docker-based scanner used for SonarQube 9.9.
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
только его файлы и предоставленные публичные контракты. Перед работой прочитай
|
||||
[порядок работы](.ai/conventions/engineering.md#граница-и-порядок-работы), затем
|
||||
объединяй 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
@@ -1,5 +1,170 @@
|
||||
# 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)
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
+6
-5
@@ -1,9 +1,9 @@
|
||||
FROM node:24.21.0-bookworm-slim AS base
|
||||
FROM node:24.22.1-bookworm-slim AS base
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS dependencies
|
||||
# Native SQLite/bcrypt builds happen only here; compilers stay out of runtime.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3=3.11.2-1+b1 make=4.3-4.1 g++=4:12.2.0-3 ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ ca-certificates openssl && rm -rf /var/lib/apt/lists/*
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
@@ -11,7 +11,7 @@ RUN npx --no-install prisma generate
|
||||
|
||||
FROM dependencies AS development
|
||||
# Nest watch uses ps to stop the previous process tree before restarting.
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends procps=2:4.0.2-3 && rm -rf /var/lib/apt/lists/*
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends procps && rm -rf /var/lib/apt/lists/*
|
||||
RUN mkdir /data && chown node:node /data && chown -R node:node /app
|
||||
USER node
|
||||
ENV NODE_ENV=development DATABASE_URL=file:/data/wealthledger.db
|
||||
@@ -19,10 +19,11 @@ EXPOSE 3000
|
||||
CMD ["npm", "run", "start:dev"]
|
||||
|
||||
FROM dependencies AS build
|
||||
RUN npm run build && npm prune --omit=dev --omit=optional
|
||||
# The client is already generated at the shared dependencies stage (same revision).
|
||||
RUN npx --no-install nest build && npm prune --omit=dev --omit=optional
|
||||
|
||||
FROM base AS production
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20250419~deb12u1 openssl=3.0.20-1~deb12u2 && rm -rf /var/lib/apt/lists/* \
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates openssl && rm -rf /var/lib/apt/lists/* \
|
||||
&& mkdir /data && chown node:node /data
|
||||
ARG VERSION
|
||||
ARG REVISION
|
||||
|
||||
@@ -14,6 +14,7 @@ cp .env.example .env
|
||||
nvm use --silent
|
||||
npm ci
|
||||
npx prisma migrate deploy
|
||||
npx prisma generate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
@@ -60,7 +61,7 @@ Compose topology принадлежит workspace, эксплуатационн
|
||||
npm run verify
|
||||
```
|
||||
|
||||
Он запускает zero-warning lint,
|
||||
Он запускает генерацию Prisma client, zero-warning lint,
|
||||
application/test typechecks, Jest с покрытием и build. `npm run lint:fix` — отдельная
|
||||
записывающая команда. `npm run test:cov` также доступен отдельно; общего числового
|
||||
threshold нет. `npm run test:e2e` отдельно проверяет функциональные HTTP suites
|
||||
@@ -80,11 +81,13 @@ TypeScript 7 требует поддержки со стороны `ts-jest` и
|
||||
Единый `.gitea/workflows/verify.yml` запускает `commits → security → verify`
|
||||
последовательно на PR; на push в `develop`/`main` — `security → verify`.
|
||||
Рабочие ветки проверяются после открытия PR, без дублирующего push-запуска.
|
||||
Изменение заголовка PR тоже повторяет проверки Conventional Commits.
|
||||
Изменение заголовка PR повторяет только проверку Conventional Commits; `security`
|
||||
и `verify` на metadata-only событии не запускаются.
|
||||
Новый запуск отменяет устаревший для того же PR/ветки и типа события
|
||||
(требуется Gitea 1.26+). Ежедневно в 05:47 UTC выполняется только `security`.
|
||||
Job `verify` запускает `npm ci`, генерацию Prisma client,
|
||||
`npm run verify` и функциональный `npm run test:e2e`. Используется только checkout
|
||||
Job `verify` запускает `npm ci` и `npm run verify` (генерация Prisma client
|
||||
внутри gate, один Nest build), затем функциональный `node test/sqlite.cjs --all`
|
||||
на уже собранных артефактах. Используется только checkout
|
||||
backend и его зависимости; OpenAPI генерируется во время проверки, SHA записывается в лог.
|
||||
Успешный unit gate не заменяет SQLite/e2e; ошибка любой suite завершает job с FAIL.
|
||||
|
||||
@@ -215,7 +218,10 @@ nvm use --silent
|
||||
npm run openapi:generate
|
||||
```
|
||||
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория.
|
||||
Команда собирает backend и записывает `openapi.json` в корень репозитория
|
||||
атомарно: через временный файл и rename, без частичной записи при сбое.
|
||||
`npm run openapi:check` только сравнивает runtime schema с файлом и завершается
|
||||
с ошибкой при расхождении, не перезаписывая его.
|
||||
Сервер и HTTP-порт не запускаются; Nest preview читает metadata без создания
|
||||
провайдеров и подключения к БД/T-Bank. Настроенный `.env` и реальные секреты
|
||||
не нужны. `openapi.json` исключён из Git и каждый раз создаётся из DTO/Swagger
|
||||
@@ -248,10 +254,14 @@ breaking changes относительно предыдущего выпуска.
|
||||
настраивается отдельно. История migrations применяется штатным `migrate deploy`.
|
||||
|
||||
Регрессия самого запуска: `node test/sqlite-regression.cjs`. Она выполняет
|
||||
три последовательных и два параллельных полных e2e, проверяет уникальность DB,
|
||||
сохранность вымышленного внешнего файла, отказ migration и SIGINT/SIGTERM cleanup.
|
||||
три последовательных и два параллельных запуска двух обычных e2e suites,
|
||||
проверяет уникальность DB, сохранность вымышленного внешнего файла, отказ migration
|
||||
и SIGINT/SIGTERM cleanup. Полный e2e набор остаётся в `npm run test:e2e`.
|
||||
Логи остаются в каталоге из вывода. Для одной suite:
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`.
|
||||
`node test/sqlite.cjs app.e2e-spec.ts`. Crash-регрессия в
|
||||
`tbank-operations-sync.e2e-spec.ts` загружает классы из `dist/`, поэтому перед
|
||||
адресным запуском этой suite выполните `npm run build`, иначе child/dist может
|
||||
расходиться с parent/src.
|
||||
|
||||
| Риск | Проверка реальной DB |
|
||||
| --- | --- |
|
||||
@@ -295,35 +305,59 @@ Durability оставляет временные DB, логи и `result.json`
|
||||
## Релизы
|
||||
|
||||
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
|
||||
релизы. Работа: `codex/<task>` от актуального `develop`, PR в `develop`.
|
||||
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
|
||||
Commits и PR titles — Conventional Commits, включая `spec:`. Версии вручную не повышай.
|
||||
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
|
||||
общие правила — в Engineering Workflow по ссылке из AGENTS.
|
||||
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
|
||||
обновляется только fast-forward из `develop`, поэтому история линейна:
|
||||
|
||||
1. Получи `main`, `develop` и теги: `git fetch origin --tags`. Убедись, что
|
||||
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
|
||||
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
|
||||
`release/hotfix-<name>` и проходит тот же процесс.
|
||||
```text
|
||||
<type>/<issue>-<slug> --squash--> develop --ff-only--> main --> tag vX.Y.Z
|
||||
```
|
||||
|
||||
Инвариант: `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,
|
||||
затем `npm run release:prepare`. Команда создаёт `chore(release): X.Y.Z`,
|
||||
обновляет manifest/lockfile/CHANGELOG; тег и push не выполняет. Не обходи
|
||||
guards ветки, чистого дерева, отсутствующих тегов или уже подготовленной версии.
|
||||
3. Проверь diff и штатные gates этого репозитория. Отправь только release-ветку,
|
||||
открой PR в `main` с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если `main`
|
||||
изменился, согласуй историю и повтори проверки кандидата.
|
||||
4. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
открой PR в **`develop`** с заголовком `chore(release): X.Y.Z`. После зелёных
|
||||
`verify`, `security`, `commits` выполни fast-forward-only merge. Если
|
||||
`develop` изменился, обнови release-ветку и повтори проверки кандидата.
|
||||
4. Открой PR `develop → main` с тем же заголовком и слей fast-forward-only.
|
||||
5. Получи удалённый `main`, сравни его HEAD с проверенным release SHA и версией
|
||||
manifest/lockfile. Создай аннотированный `vX.Y.Z` именно на этом SHA:
|
||||
`git tag -a vX.Y.Z <release-sha> -m "Release X.Y.Z"`, затем отправь только
|
||||
`git push origin refs/tags/vX.Y.Z`. Не запускай release-it повторно на `main`.
|
||||
5. Верни release-коммит через PR `main → develop` без squash/rebase; проверь,
|
||||
что `develop` содержит тег. Опубликованные теги не перемещай. При сбое после
|
||||
merge создай отсутствующий тег без нового bump; до merge исправь и проверь
|
||||
существующий кандидат без повторного повышения версии.
|
||||
Обратная синхронизация не нужна: release-коммит попал в `develop` на шаге 3.
|
||||
Опубликованные теги не перемещай. При сбое после 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;
|
||||
`!`/`BREAKING CHANGE:` — major, включая версии до 1.0. `chore(release):`
|
||||
исключён из рекомендации и changelog. Конфигурация — `.release-it.json`.
|
||||
В Gitea для `main`/`develop` должны быть запрещены прямой/force push и обход
|
||||
администратором, обязательны `verify`, `security`, `commits`; теги `v*` защищены.
|
||||
администратором, включена блокировка устаревшей ветки, обязательны `verify`,
|
||||
`security`, `commits`; теги `v*` защищены. Линейность обеспечивают merge styles
|
||||
репозитория: merge-коммиты и rebase `--no-ff` выключены, доступны squash,
|
||||
rebase-merge и fast-forward-only; стиль по умолчанию — squash.
|
||||
Workflow сам не включает эти настройки. Отдельное review approval для одного
|
||||
владельца не требуется. Npm publishing, deploy и Gitea Releases не включены.
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
module.exports = {
|
||||
extends: ['@commitlint/config-conventional'],
|
||||
rules: { 'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']] },
|
||||
defaultIgnores: false,
|
||||
ignores: [
|
||||
(message) => /^chore\(release\): \d+\.\d+\.\d+$/.test(message.trim()),
|
||||
(message) => /^Merge pull request ['"].+['"] \(#\d+\) from .+ into .+$/.test(message.trim()),
|
||||
],
|
||||
rules: {
|
||||
'type-enum': [2, 'always', ['feat', 'fix', 'perf', 'docs', 'spec', 'refactor', 'build', 'ci', 'test', 'style', 'chore', 'revert']],
|
||||
'references-empty': [2, 'never'],
|
||||
},
|
||||
};
|
||||
|
||||
@@ -32,11 +32,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/income/income-analytics.service.ts": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -58,18 +53,7 @@
|
||||
"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": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 2
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
@@ -102,25 +86,17 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/investment-resource.mapper.ts": {
|
||||
"src/modules/investments/investment-sync-plan.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"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": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 2
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 9
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
||||
@@ -149,32 +125,11 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/portfolio/application/portfolio.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-screen.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 7
|
||||
}
|
||||
},
|
||||
"src/modules/investments/upcoming-events/upcoming-events.service.ts": {
|
||||
"sonarjs/cognitive-complexity": {
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"test/bank-cards-durability.cjs": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -193,9 +148,6 @@
|
||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+33
-3
@@ -15,6 +15,18 @@ import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
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 = [
|
||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||
];
|
||||
@@ -37,7 +49,7 @@ export default defineConfig(
|
||||
linterOptions: { reportUnusedDisableDirectives: 'error' },
|
||||
settings: {
|
||||
'import-x/resolver-next': [createTypeScriptImportResolver()],
|
||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.21.0' },
|
||||
'node': { tryExtensions: ['.ts', '.js', '.json', '.node'], version: '24.22.1' },
|
||||
},
|
||||
rules: {
|
||||
'curly': ['error', 'multi-line'],
|
||||
@@ -56,6 +68,7 @@ export default defineConfig(
|
||||
'after',
|
||||
{ overrides: { '?': 'before', ':': 'before' } },
|
||||
],
|
||||
'import-x/no-cycle': 'error',
|
||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||
},
|
||||
},
|
||||
@@ -157,13 +170,14 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.use-case.ts'],
|
||||
files: ['src/**/application/**/*.ts'],
|
||||
ignores: tests,
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: prismaImports,
|
||||
message: 'Use cases obtain data through concrete repositories.',
|
||||
allowTypeImports: true,
|
||||
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
@@ -211,4 +225,20 @@ export default defineConfig(
|
||||
'@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.`,
|
||||
})),
|
||||
}],
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
Generated
+1057
-1328
File diff suppressed because it is too large
Load Diff
+69
-46
@@ -1,20 +1,31 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.0.0",
|
||||
"version": "2.4.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#generated/*": {
|
||||
"development": "./dist-dev/generated/*",
|
||||
"default": "./dist/generated/*"
|
||||
},
|
||||
"#src/*": {
|
||||
"development": "./dist-dev/src/*",
|
||||
"default": "./dist/src/*"
|
||||
}
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "v24.21.0"
|
||||
"node": "v24.22.1"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "prisma generate && nest build",
|
||||
"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",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=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:prod": "node dist/src/main.js",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint . --max-warnings=0",
|
||||
@@ -31,96 +42,109 @@
|
||||
"test:container": "node test/container-smoke.cjs",
|
||||
"test:durability": "node test/bank-cards-durability.cjs",
|
||||
"test:openapi-contract": "npm run build && node test/sqlite.cjs openapi-contract.e2e-spec.ts",
|
||||
"verify": "npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && npm run build",
|
||||
"verify": "prisma generate && npm run lint && npm run test:lint && npm run typecheck:app && npm run typecheck:test && npm run test:cov -- --runInBand && nest build",
|
||||
"release:preview": "release-it --dry-run --ci",
|
||||
"release:prepare": "release-it --ci",
|
||||
"commitlint": "commitlint",
|
||||
"test:lint": "node --test test/eslint-config.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.5",
|
||||
"@grpc/grpc-js": "1.14.6",
|
||||
"@grpc/proto-loader": "0.8.1",
|
||||
"@nestjs/common": "12.0.1",
|
||||
"@nestjs/config": "12.0.0",
|
||||
"@nestjs/core": "12.0.1",
|
||||
"@nestjs/jwt": "12.0.1",
|
||||
"@nestjs/common": "12.2.0",
|
||||
"@nestjs/config": "12.0.1",
|
||||
"@nestjs/core": "12.2.0",
|
||||
"@nestjs/jwt": "12.0.3",
|
||||
"@nestjs/passport": "12.0.0",
|
||||
"@nestjs/platform-express": "12.0.1",
|
||||
"@nestjs/swagger": "12.0.1",
|
||||
"@prisma/adapter-better-sqlite3": "7.10.0",
|
||||
"@prisma/client": "7.10.0",
|
||||
"@nestjs/platform-express": "12.2.0",
|
||||
"@nestjs/swagger": "12.0.4",
|
||||
"@prisma/adapter-better-sqlite3": "7.10.1",
|
||||
"@prisma/client": "7.10.1",
|
||||
"bcrypt": "6.0.0",
|
||||
"better-sqlite3": "13.0.3",
|
||||
"better-sqlite3": "12.11.1",
|
||||
"cheerio": "1.2.0",
|
||||
"class-transformer": "0.5.1",
|
||||
"class-validator": "0.15.1",
|
||||
"cookie-parser": "1.4.7",
|
||||
"dotenv": "17.4.2",
|
||||
"lru-cache": "11.5.2",
|
||||
"nestjs-pino": "5.1.0",
|
||||
"lru-cache": "11.5.3",
|
||||
"nestjs-pino": "5.3.1",
|
||||
"passport": "0.7.0",
|
||||
"passport-jwt": "4.0.1",
|
||||
"pino-http": "11.0.0",
|
||||
"prisma": "7.10.0",
|
||||
"prisma": "7.10.1",
|
||||
"reflect-metadata": "0.2.2",
|
||||
"rxjs": "7.8.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "21.2.2",
|
||||
"@commitlint/config-conventional": "21.2.2",
|
||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.6",
|
||||
"@commitlint/cli": "21.2.4",
|
||||
"@commitlint/config-conventional": "21.2.4",
|
||||
"@darraghor/eslint-plugin-nestjs-typed": "7.5.8",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@nestjs/cli": "12.0.0",
|
||||
"@nestjs/schematics": "12.0.1",
|
||||
"@nestjs/testing": "12.0.1",
|
||||
"@release-it/conventional-changelog": "12.0.0",
|
||||
"@nestjs/cli": "12.0.10",
|
||||
"@nestjs/schematics": "12.0.10",
|
||||
"@nestjs/testing": "12.2.0",
|
||||
"@release-it/conventional-changelog": "12.0.2",
|
||||
"@stylistic/eslint-plugin": "5.10.0",
|
||||
"@types/bcrypt": "6.0.0",
|
||||
"@types/cookie-parser": "1.4.10",
|
||||
"@types/express": "5.0.6",
|
||||
"@types/jest": "30.0.0",
|
||||
"@types/node": "24.13.4",
|
||||
"@types/node": "24.19.2",
|
||||
"@types/passport-jwt": "4.0.1",
|
||||
"@types/supertest": "7.2.1",
|
||||
"conventional-changelog-conventionalcommits": "10.4.0",
|
||||
"eslint": "10.11.0",
|
||||
"conventional-changelog-conventionalcommits": "10.4.1",
|
||||
"eslint": "10.13.0",
|
||||
"eslint-import-resolver-typescript": "4.4.5",
|
||||
"eslint-plugin-import-x": "4.17.1",
|
||||
"eslint-plugin-jest": "29.16.6",
|
||||
"eslint-plugin-n": "18.4.0",
|
||||
"eslint-plugin-perfectionist": "5.12.1",
|
||||
"eslint-plugin-jest": "29.17.0",
|
||||
"eslint-plugin-n": "18.5.0",
|
||||
"eslint-plugin-perfectionist": "5.13.0",
|
||||
"eslint-plugin-regexp": "3.3.1",
|
||||
"eslint-plugin-security": "4.1.0",
|
||||
"eslint-plugin-sonarjs": "4.2.1",
|
||||
"globals": "17.12.0",
|
||||
"jest": "30.5.1",
|
||||
"release-it": "21.0.2",
|
||||
"source-map-support": "0.5.21",
|
||||
"supertest": "7.2.2",
|
||||
"ts-jest": "29.4.12",
|
||||
"eslint-plugin-security": "4.2.0",
|
||||
"eslint-plugin-sonarjs": "4.2.2",
|
||||
"globals": "17.13.0",
|
||||
"jest": "30.5.2",
|
||||
"pino-pretty": "13.2.0",
|
||||
"release-it": "21.1.1",
|
||||
"supertest": "7.3.1",
|
||||
"ts-jest": "29.4.15",
|
||||
"ts-loader": "9.6.2",
|
||||
"ts-node": "10.9.2",
|
||||
"tsconfig-paths": "4.2.0",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.70.1"
|
||||
"typescript-eslint": "8.72.0"
|
||||
},
|
||||
"overrides": {
|
||||
"proxy-addr": "2.0.8",
|
||||
"express": {
|
||||
"body-parser": "2.4.0"
|
||||
},
|
||||
"@nestjs/platform-express": {
|
||||
"multer": "2.4.0"
|
||||
"multer": "2.5.0"
|
||||
},
|
||||
"ajv": {
|
||||
"fast-uri": "3.1.9"
|
||||
},
|
||||
"@prisma/config@7.10.0": {
|
||||
"deepmerge-ts": "8.0.2"
|
||||
},
|
||||
"release-it": {
|
||||
"basic-ftp": "6.2.1",
|
||||
"undici": "7.29.1"
|
||||
"basic-ftp": "6.2.3",
|
||||
"undici": "7.30.1"
|
||||
},
|
||||
"socks": {
|
||||
"ip-address": "10.7.4"
|
||||
},
|
||||
"prisma": {
|
||||
"mysql2": "3.24.4"
|
||||
"mysql2": "3.24.6"
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
"^#generated/(.*)$": "<rootDir>/../generated/$1",
|
||||
"^#src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
@@ -144,7 +168,6 @@
|
||||
"better-sqlite3@12.11.1": true,
|
||||
"fsevents@2.3.3": true,
|
||||
"protobufjs@7.6.5": true,
|
||||
"unrs-resolver@1.12.2": true,
|
||||
"better-sqlite3@13.0.3": true
|
||||
"unrs-resolver@1.12.2": true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "TbankPortfolioValueHistory" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"deletedAt" DATETIME,
|
||||
"updatedAt" DATETIME NOT NULL,
|
||||
|
||||
PRIMARY KEY ("userId", "accountId"),
|
||||
CONSTRAINT "TbankPortfolioValueHistory_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "TbankPortfolioValuePoint" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"accountId" TEXT NOT NULL,
|
||||
"attemptId" TEXT NOT NULL,
|
||||
"currency" TEXT NOT NULL,
|
||||
"value" TEXT NOT NULL,
|
||||
"capturedAt" DATETIME NOT NULL,
|
||||
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
PRIMARY KEY ("userId", "accountId", "attemptId", "currency"),
|
||||
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Points written before WealthLedger/backend#140 counted cash twice (cash balance
|
||||
-- plus the mirroring CURRENCY position). Historical compositions and FX rates are
|
||||
-- not stored, so the points cannot be recomputed exactly; history restarts here.
|
||||
DELETE FROM "TbankPortfolioValuePoint";
|
||||
@@ -0,0 +1,11 @@
|
||||
CREATE TABLE "TbankInstrumentClassification" (
|
||||
"provider" TEXT NOT NULL,
|
||||
"instrumentKey" TEXT NOT NULL,
|
||||
"sector" TEXT,
|
||||
"sectorFetchedAt" DATETIME,
|
||||
"assetUid" TEXT,
|
||||
"brandUid" TEXT,
|
||||
"brandName" TEXT,
|
||||
"brandFetchedAt" DATETIME,
|
||||
PRIMARY KEY ("provider","instrumentKey")
|
||||
);
|
||||
@@ -17,6 +17,7 @@ model User {
|
||||
bankCardOperations BankCardOperation[]
|
||||
sessions Session[]
|
||||
tbankConnection TbankConnection?
|
||||
tbankValueHistory TbankPortfolioValueHistory[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
@@ -69,6 +70,34 @@ model TbankPortfolio {
|
||||
@@id([userId, revision, accountId])
|
||||
}
|
||||
|
||||
// Account valuation history survives connection replacement and stays hidden
|
||||
// until the current connection confirms the account again.
|
||||
model TbankPortfolioValueHistory {
|
||||
userId String
|
||||
accountId String
|
||||
deletedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
points TbankPortfolioValuePoint[]
|
||||
|
||||
@@id([userId, accountId])
|
||||
}
|
||||
|
||||
// One row per currency of a fully valued successful portfolio attempt.
|
||||
model TbankPortfolioValuePoint {
|
||||
userId String
|
||||
accountId String
|
||||
attemptId String
|
||||
currency String
|
||||
value String
|
||||
capturedAt DateTime
|
||||
createdAt DateTime @default(now())
|
||||
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||
|
||||
@@id([userId, accountId, attemptId, currency])
|
||||
@@index([userId, accountId, capturedAt])
|
||||
}
|
||||
|
||||
model TbankOperation {
|
||||
userId String
|
||||
revision String
|
||||
@@ -157,6 +186,20 @@ model TbankInstrumentLookup {
|
||||
@@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.
|
||||
model TbankInstrumentSource {
|
||||
provider String
|
||||
|
||||
+30
-1
@@ -2,7 +2,8 @@
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"extends": [
|
||||
"config:recommended",
|
||||
":semanticCommitTypeAll(chore)"
|
||||
":semanticCommitTypeAll(chore)",
|
||||
"security:minimumReleaseAgeNpm"
|
||||
],
|
||||
"timezone": "Europe/Moscow",
|
||||
"labels": [
|
||||
@@ -10,6 +11,13 @@
|
||||
],
|
||||
"dependencyDashboard": true,
|
||||
"rangeStrategy": "pin",
|
||||
"prConcurrentLimit": 5,
|
||||
"lockFileMaintenance": {
|
||||
"enabled": true,
|
||||
"schedule": [
|
||||
"before 4am on the first day of the month"
|
||||
]
|
||||
},
|
||||
"packageRules": [
|
||||
{
|
||||
"matchUpdateTypes": [
|
||||
@@ -18,6 +26,27 @@
|
||||
],
|
||||
"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": [
|
||||
"major"
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
const { randomBytes } = require('node:crypto');
|
||||
const { writeFile } = require('node:fs/promises');
|
||||
const { readFile, rename, rm, writeFile } = require('node:fs/promises');
|
||||
const { resolve } = require('node:path');
|
||||
|
||||
// Relative to the working directory, as before: the contract test runs the script in a temporary directory.
|
||||
const target = resolve(process.cwd(), 'openapi.json');
|
||||
const checkOnly = process.argv.includes('--check');
|
||||
|
||||
// Schema generation needs no deployment configuration or real credentials.
|
||||
Object.assign(process.env, {
|
||||
@@ -19,23 +24,48 @@ const { NestFactory } = require('@nestjs/core');
|
||||
const { AppModule } = require('../dist/src/app.module.js');
|
||||
const { API_PREFIX, createOpenApiDocument } = require('../dist/src/openapi.js');
|
||||
|
||||
async function generate() {
|
||||
async function render() {
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
preview: true,
|
||||
logger: false,
|
||||
});
|
||||
try {
|
||||
app.setGlobalPrefix(API_PREFIX);
|
||||
await writeFile(
|
||||
'openapi.json',
|
||||
JSON.stringify(createOpenApiDocument(app), null, 2) + '\n',
|
||||
);
|
||||
return JSON.stringify(createOpenApiDocument(app), null, 2) + '\n';
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
|
||||
generate().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
// Read-only: compares the runtime document with the committed artifact and never writes.
|
||||
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);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, ValidationPipe } from '@nestjs/common';
|
||||
|
||||
import { CredentialsDto } from '../modules/auth/presentation/auth.dto';
|
||||
import { LoginDto, RegisterDto } from '../modules/auth/presentation/auth.dto';
|
||||
import {
|
||||
CreateBankCardDto,
|
||||
UpdateBankCardDto,
|
||||
@@ -11,7 +11,7 @@ describe('DTO validation', () => {
|
||||
forbidUnknownValues: true, transform: true, whitelist: true });
|
||||
|
||||
it('requires credentials and normalizes a valid email', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CredentialsDto };
|
||||
const metadata = { type: 'body' as const, metatype: RegisterDto };
|
||||
await expect(pipe.transform({}, metadata)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
@@ -29,6 +29,59 @@ describe('DTO validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Test intent: register accepts 8..72 printable ASCII characters only (bcrypt
|
||||
// reads 72 bytes, so non-ASCII could be silently truncated); login only caps
|
||||
// bytes so accounts created under the older policy can still sign in.
|
||||
describe('password policy', () => {
|
||||
const register = { type: 'body' as const, metatype: RegisterDto };
|
||||
const login = { type: 'body' as const, metatype: LoginDto };
|
||||
const body = (password: string) => ({ email: 'user@example.com', password });
|
||||
const accepts = (value: unknown, metadata: typeof login | typeof register) =>
|
||||
pipe.transform(value, metadata).then(
|
||||
() => true,
|
||||
(error: unknown) => {
|
||||
if (!(error instanceof BadRequestException)) throw error;
|
||||
return false;
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
['7 chars', 'a'.repeat(7), false],
|
||||
['8 chars', 'a'.repeat(8), true],
|
||||
['72 chars', 'a'.repeat(72), true],
|
||||
['73 chars', 'a'.repeat(73), false],
|
||||
['all printable symbols and space', 'Aa1 !~"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}', true],
|
||||
['Cyrillic, 8+ chars', 'пароль-пароль', false],
|
||||
['emoji', 'passw😀rd', false],
|
||||
['tab', 'pass\tword1', false],
|
||||
['newline', 'password1\n', false],
|
||||
['DEL', 'password\x7f1', false],
|
||||
['non-string', 12345678, false],
|
||||
])('register: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), register)).resolves.toBe(accepted);
|
||||
});
|
||||
|
||||
it('register keeps raw whitespace untouched', async () => {
|
||||
const password = ' spaced pass ';
|
||||
await expect(pipe.transform(body(password), register)).resolves.toEqual(
|
||||
body(password),
|
||||
);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['legacy Cyrillic password', 'пароль-пароль', true],
|
||||
['short legacy password', 'short', true],
|
||||
['72 bytes', 'a'.repeat(72), true],
|
||||
['73 bytes', 'a'.repeat(73), false],
|
||||
['36 Cyrillic letters = 72 bytes', 'я'.repeat(36), true],
|
||||
['37 Cyrillic letters = 74 bytes', 'я'.repeat(37), false],
|
||||
['empty', '', false],
|
||||
['non-string', 12345678, false],
|
||||
])('login: %s', async (_, password, accepted) => {
|
||||
await expect(accepts(body(password as string), login)).resolves.toBe(accepted);
|
||||
});
|
||||
});
|
||||
|
||||
it('requires and validates nested money in a new card', async () => {
|
||||
const metadata = { type: 'body' as const, metatype: CreateBankCardDto };
|
||||
const card = {
|
||||
|
||||
@@ -1,24 +1,29 @@
|
||||
import { ArgumentsHost } from '@nestjs/common';
|
||||
import { ArgumentsHost, BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||
import { HttpExceptionFilter } from './http-exception.filter';
|
||||
|
||||
describe('HttpExceptionFilter', () => {
|
||||
it('does not expose internal error details', () => {
|
||||
const setup = () => {
|
||||
const status = jest.fn().mockReturnThis();
|
||||
const type = jest.fn().mockReturnThis();
|
||||
const json = jest.fn();
|
||||
const locals: Record<string, unknown> = {};
|
||||
const filter = new HttpExceptionFilter();
|
||||
const logger = { event: jest.fn() };
|
||||
const filter = new HttpExceptionFilter(logger as unknown as StructuredLogger);
|
||||
const host = {
|
||||
switchToHttp: () => ({
|
||||
getResponse: () => ({ status, type, json, locals }),
|
||||
}),
|
||||
} as unknown as ArgumentsHost;
|
||||
return { filter, host, status, type, json, locals, logger };
|
||||
};
|
||||
|
||||
filter.catch(
|
||||
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
|
||||
host,
|
||||
);
|
||||
it('logs unhandled errors with name and stack frames but never the message', () => {
|
||||
const { filter, host, status, type, json, locals, logger } = setup();
|
||||
const error = new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET');
|
||||
|
||||
filter.catch(error, host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(500);
|
||||
expect(type).toHaveBeenCalledWith('application/problem+json');
|
||||
@@ -27,6 +32,29 @@ describe('HttpExceptionFilter', () => {
|
||||
code: 'INTERNAL_SERVER_ERROR',
|
||||
});
|
||||
expect(locals.publicErrorCode).toBe('INTERNAL_SERVER_ERROR');
|
||||
expect(locals.errorStack).toBeUndefined();
|
||||
expect(logger.event).toHaveBeenCalledTimes(1);
|
||||
const [level, event, fields] = logger.event.mock.calls[0] as [string, string, Record<string, unknown>];
|
||||
expect(level).toBe('error');
|
||||
expect(event).toBe('http.request.unhandled');
|
||||
expect(fields).toMatchObject({ code: 'INTERNAL_SERVER_ERROR', errorName: 'Error' });
|
||||
expect(JSON.stringify(fields)).not.toContain('SYNTHETIC_AUDIT_SECRET');
|
||||
expect(String(fields['errorStack']).split('\n').every((line) => line.startsWith(' at '))).toBe(true);
|
||||
});
|
||||
|
||||
it('logs field pointers without values for validation errors', () => {
|
||||
const { filter, host, status, logger, locals } = setup();
|
||||
const exception = new BadRequestException({
|
||||
message: [
|
||||
{ property: 'amount', constraints: { isPositive: 'amount must be positive' }, value: -42 },
|
||||
{ property: 'amount', constraints: { min: 'amount too small' }, value: -42 },
|
||||
],
|
||||
});
|
||||
|
||||
filter.catch(exception, host);
|
||||
|
||||
expect(status).toHaveBeenCalledWith(400);
|
||||
expect(locals.invalidFields).toBe('#/amount');
|
||||
expect(logger.event).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(locals)).not.toContain('-42');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { HttpErrorCode } from '../error-code';
|
||||
import { StructuredLogger } from '../logging/structured-logger.service';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
constructor(private readonly logger: StructuredLogger) {}
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const context = host.switchToHttp();
|
||||
const response = context.getResponse<Response>();
|
||||
@@ -31,6 +34,18 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
: this.codeFor(status, payload);
|
||||
response.locals ??= {};
|
||||
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
|
||||
.status(status)
|
||||
.type('application/problem+json')
|
||||
@@ -47,6 +62,20 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
});
|
||||
}
|
||||
|
||||
// ponytail: stack содержит текст message в первой строке; оставляем только кадры, message не пишем.
|
||||
private logUnhandled(exception: unknown, code: string): void {
|
||||
const error = exception instanceof Error ? exception : undefined;
|
||||
const frames = (error?.stack ?? '')
|
||||
.split('\n')
|
||||
.filter((line) => line.startsWith(' at '))
|
||||
.join('\n');
|
||||
this.logger.event('error', 'http.request.unhandled', {
|
||||
code,
|
||||
errorName: error?.name ?? typeof exception,
|
||||
errorStack: frames,
|
||||
});
|
||||
}
|
||||
|
||||
private codeFor(status: number, payload: unknown): string {
|
||||
if (typeof payload === 'object' && payload !== null && 'code' in payload) {
|
||||
const code = (payload as { code?: unknown }).code;
|
||||
|
||||
@@ -24,6 +24,7 @@ function completion(request: Request, response: Response, value: { responseTime:
|
||||
status: response.statusCode,
|
||||
durationMs: Math.round(value.responseTime),
|
||||
...(typeof response.locals.publicErrorCode === 'string' ? { code: response.locals.publicErrorCode } : {}),
|
||||
...(typeof response.locals.invalidFields === 'string' ? { invalidFields: response.locals.invalidFields } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -31,6 +32,10 @@ export function loggingOptions(config: ConfigService<Environment, true>): Option
|
||||
return {
|
||||
level: config.getOrThrow('LOG_LEVEL'),
|
||||
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) => {
|
||||
const id = randomUUID();
|
||||
response.setHeader('X-Request-ID', id);
|
||||
|
||||
@@ -14,6 +14,7 @@ describe('StructuredLogger', () => {
|
||||
const logger = new StructuredLogger(pino as never);
|
||||
|
||||
logger.info('event', {
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
password: 'secret',
|
||||
@@ -21,6 +22,7 @@ describe('StructuredLogger', () => {
|
||||
|
||||
expect(pino.info).toHaveBeenCalledWith(
|
||||
{
|
||||
asOf: '2026-10-10T12:00:00.000Z',
|
||||
userId: 'user',
|
||||
brokerageAccountId: 'account',
|
||||
},
|
||||
|
||||
@@ -5,21 +5,30 @@ type LogLevel = 'debug' | 'error' | 'info' | 'warn';
|
||||
type LogContext = Record<string, boolean | number | string | undefined>;
|
||||
|
||||
const allowedFields = new Set([
|
||||
'asOf',
|
||||
'attempts',
|
||||
'brokerageAccountId',
|
||||
'channelState',
|
||||
'clientRequestId',
|
||||
'code',
|
||||
'count',
|
||||
'durationMs',
|
||||
'environment',
|
||||
'errorName',
|
||||
'errorStack',
|
||||
'found',
|
||||
'grpcCode',
|
||||
'headersReceived',
|
||||
'invalidFields',
|
||||
'method',
|
||||
'networkMs',
|
||||
'operation',
|
||||
'queueWaitMs',
|
||||
'requestId',
|
||||
'route',
|
||||
'service',
|
||||
'status',
|
||||
'trackingId',
|
||||
'userId',
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||
|
||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||
const FIRST_PAGE_OFFSET = 0;
|
||||
|
||||
@@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||
|
||||
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||
|
||||
const EXPECTED_REGISTRY_TABLES = 1;
|
||||
const RATING_COLUMN = 2;
|
||||
|
||||
@@ -5,14 +5,15 @@ import { isDeepStrictEqual } from 'node:util';
|
||||
import type {
|
||||
TbankInstrumentLookup as MetadataLookupRow,
|
||||
TbankInstrument as MetadataRow,
|
||||
Prisma,
|
||||
} from '../../../generated/prisma/client';
|
||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
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 { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
@@ -120,11 +121,8 @@ export class InstrumentMetadataRepository {
|
||||
active: ReadonlySet<string>,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankConnection.updateMany({
|
||||
where: { ...attemptOwner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!valid.count) return null;
|
||||
const valid = await lockConnectionGeneration(tx, attemptOwner);
|
||||
if (!valid) return null;
|
||||
const scope = {
|
||||
...attemptOwner,
|
||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||
@@ -132,11 +130,7 @@ export class InstrumentMetadataRepository {
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
) {
|
||||
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
@@ -172,15 +166,11 @@ export class InstrumentMetadataRepository {
|
||||
|
||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
const aliases = new Map(
|
||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||
);
|
||||
@@ -217,9 +207,7 @@ export class InstrumentMetadataRepository {
|
||||
const previous = await tx.tbankInstrument.findUnique({
|
||||
where: { provider_uid: key },
|
||||
});
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(instrument),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson(instrument);
|
||||
const data = {
|
||||
metadata: payload,
|
||||
figi:
|
||||
@@ -269,18 +257,17 @@ export class InstrumentMetadataRepository {
|
||||
}
|
||||
|
||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
...(success ? { connection: { invalidToken: false } } : {}),
|
||||
},
|
||||
data: {
|
||||
await claimSyncWork(
|
||||
this.prisma,
|
||||
attempt,
|
||||
{
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
{ withoutConnectionFence: !success },
|
||||
);
|
||||
if (success) {
|
||||
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
@@ -320,11 +307,7 @@ function selectMetadata(
|
||||
return {
|
||||
data: usefulUid ? uid.data : (figi.data ?? uid.data),
|
||||
complete,
|
||||
fresh:
|
||||
complete &&
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- The conditional aggregation is nullable at runtime; keep the freshness guard.
|
||||
fetchedAt !== null &&
|
||||
Date.now() - fetchedAt.valueOf() < tbankSourceTtlMs.metadata,
|
||||
fresh: complete,
|
||||
fetchedAt,
|
||||
dataRevision: used
|
||||
.map(
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||
@@ -25,6 +26,22 @@ export type TbankSyncAttempt = {
|
||||
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()
|
||||
export class TbankAccessService {
|
||||
constructor(
|
||||
@@ -40,11 +57,8 @@ export class TbankAccessService {
|
||||
force: boolean,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { userId, revision, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, { userId, revision });
|
||||
if (!current) return null;
|
||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -62,11 +76,7 @@ export class TbankAccessService {
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
active.has(previous.attemptId)
|
||||
) {
|
||||
if (isJoinableRunningAttempt(previous, processRunId, active)) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
@@ -98,15 +108,11 @@ export class TbankAccessService {
|
||||
|
||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
const result = await claimSyncWork(this.prisma, attempt, {
|
||||
state: 'failed',
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
return result === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||
@@ -121,6 +127,23 @@ export class TbankAccessService {
|
||||
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
|
||||
}
|
||||
|
||||
// Online reads: a permanent upstream auth refusal becomes 400/INVALID_TBANK_TOKEN here, once, for every caller.
|
||||
async online<T>(
|
||||
userId: string,
|
||||
run: (connection: { revision: string; token: string }) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const connection = await this.prisma.tbankConnection.findUnique({
|
||||
where: { userId },
|
||||
select: { encryptedToken: true, invalidToken: true, revision: true },
|
||||
});
|
||||
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
const token = this.tokens.decrypt(connection.encryptedToken);
|
||||
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
|
||||
}
|
||||
|
||||
async publishMembership(
|
||||
attempt: TbankSyncAttempt,
|
||||
accounts: TbankAccount[],
|
||||
@@ -134,15 +157,8 @@ export class TbankAccessService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Acquire the write fence before reading membership. Replacement and
|
||||
// attempt supersession cannot interleave with the publication below.
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||
if (!claimed) return false;
|
||||
const { userId, revision } = attempt;
|
||||
const previous = await tx.tbankAccount.findMany({
|
||||
where: { userId, revision },
|
||||
@@ -192,6 +208,17 @@ export class TbankAccessService {
|
||||
membershipRevision: { increment: changed ? MEMBERSHIP_REVISION_INCREMENT : UNCHANGED_MEMBERSHIP_REVISION_INCREMENT },
|
||||
},
|
||||
});
|
||||
// Value history visibility follows the confirmed membership only; a
|
||||
// failed fetch never reaches this transaction and changes nothing.
|
||||
const supportedIds = supported.map(({ id }) => id);
|
||||
await tx.tbankPortfolioValueHistory.updateMany({
|
||||
where: { userId, accountId: { in: supportedIds } },
|
||||
data: { deletedAt: null },
|
||||
});
|
||||
await tx.tbankPortfolioValueHistory.updateMany({
|
||||
where: { userId, accountId: { notIn: supportedIds }, deletedAt: null },
|
||||
data: { deletedAt: fetchedAt },
|
||||
});
|
||||
if (completeWork) {
|
||||
await tx.tbankSyncWork.update({
|
||||
where: { id: attempt.id },
|
||||
@@ -204,11 +231,11 @@ export class TbankAccessService {
|
||||
|
||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'failed',
|
||||
errorCode: TbankErrorCode.InvalidToken,
|
||||
}, { withoutConnectionFence: true });
|
||||
if (!claimed) return false;
|
||||
await tx.tbankConnection.update({
|
||||
where: {
|
||||
userId_revision: {
|
||||
|
||||
@@ -8,7 +8,9 @@ import type {
|
||||
export type TbankShare = TbankInstrument & {
|
||||
apiTradeAvailableFlag?: boolean;
|
||||
assetUid?: string;
|
||||
buyAvailableFlag?: boolean;
|
||||
classCode?: string;
|
||||
countryOfRisk?: string;
|
||||
currency?: string;
|
||||
figi?: string;
|
||||
first_1dayCandleDate?: TbankTimestamp;
|
||||
|
||||
@@ -5,6 +5,10 @@ export enum InstrumentIdType {
|
||||
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentStatus {
|
||||
All = 'INSTRUMENT_STATUS_ALL',
|
||||
}
|
||||
|
||||
export enum TbankInstrumentType {
|
||||
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
tbankSourceTtlMs,
|
||||
} from './tbank-source.policy';
|
||||
|
||||
describe('request-driven source freshness', () => {
|
||||
describe('mutable source freshness', () => {
|
||||
it.each(Object.keys(tbankSourceTtlMs) as (keyof typeof tbankSourceTtlMs)[])(
|
||||
'%s expires at its boundary without changing source time',
|
||||
(source) => {
|
||||
|
||||
@@ -1,15 +1,10 @@
|
||||
// Freshness never establishes completeness. Immutable ranges are planned by
|
||||
// their coverage owners; these TTLs apply only to mutable source snapshots.
|
||||
export const tbankSourceTtlMs = {
|
||||
membership: 300_000,
|
||||
portfolio: 60_000,
|
||||
quote: 60_000,
|
||||
candleTail: 60_000,
|
||||
operationsTail: 300_000,
|
||||
metadata: 86_400_000,
|
||||
catalog: 86_400_000,
|
||||
company: 86_400_000,
|
||||
calendar: 86_400_000,
|
||||
membership: 3_600_000,
|
||||
portfolio: 3_600_000,
|
||||
quote: 3_600_000,
|
||||
candleTail: 3_600_000,
|
||||
} as const;
|
||||
|
||||
export function tbankSourceNeedsRefresh(
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
|
||||
const NO_UPDATED_ROWS = 0;
|
||||
|
||||
// Shared owners for the tbank-sync DB claim/lock protocol.
|
||||
// Both helpers are CAS fences: they run inside the caller's transaction and
|
||||
// keep the exact where/data shapes they replaced. Callers keep deciding
|
||||
// success from the claim count exactly as before.
|
||||
|
||||
// Claims the active TbankSyncWork row: only the still-running attempt of a
|
||||
// valid connection may transition. `data` is the state transition to apply.
|
||||
export type ClaimOptions = {
|
||||
// Overrides the default 'running' fence (e.g. an initiator row also
|
||||
// accepts 'complete').
|
||||
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
|
||||
// true drops the `connection: { invalidToken: false }` guard for fences
|
||||
// that must also fire on an invalidated connection.
|
||||
withoutConnectionFence: boolean | undefined;
|
||||
};
|
||||
export async function claimSyncWork(
|
||||
tx: Prisma.TransactionClient,
|
||||
attempt: TbankSyncAttempt,
|
||||
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
|
||||
options: Partial<ClaimOptions> = {},
|
||||
): Promise<number> {
|
||||
const result = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: options.state ?? 'running',
|
||||
...(options.withoutConnectionFence
|
||||
? {}
|
||||
: { connection: { invalidToken: false } }),
|
||||
},
|
||||
data,
|
||||
});
|
||||
return result.count;
|
||||
}
|
||||
|
||||
// Locks the connection generation before reserving or publishing work: the
|
||||
// row must still be the current revision with a valid token. `extra`
|
||||
// (e.g. account membership) is merged into the where clause.
|
||||
export async function lockConnectionGeneration(
|
||||
tx: Prisma.TransactionClient,
|
||||
owner: { revision: string; userId: string },
|
||||
extra: Prisma.TbankConnectionWhereInput = {},
|
||||
): Promise<boolean> {
|
||||
const result = await tx.tbankConnection.updateMany({
|
||||
where: { ...owner, ...extra, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
return result.count > NO_UPDATED_ROWS;
|
||||
}
|
||||
@@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
|
||||
export { metadataKey } from './instrument-metadata.service';
|
||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||
export {
|
||||
isJoinableRunningAttempt,
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { Metadata, status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { EventEmitter } from 'node:events';
|
||||
|
||||
import { TbankOperationState } from './tbank-protocol';
|
||||
import { TbankClient } from './tbank.client';
|
||||
@@ -136,6 +137,70 @@ describe('TbankClient operations', () => {
|
||||
);
|
||||
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', () => {
|
||||
@@ -171,6 +236,16 @@ describe('TbankClient metadata retries', () => {
|
||||
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([
|
||||
status.UNAVAILABLE,
|
||||
status.DEADLINE_EXCEEDED,
|
||||
@@ -282,3 +357,141 @@ describe('TbankClient metadata retries', () => {
|
||||
expect(jest.getTimerCount()).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('TbankClient share catalog', () => {
|
||||
const token = 'token';
|
||||
const catalogSize = 40;
|
||||
let client: TbankClient;
|
||||
let call: jest.Mock;
|
||||
let logger: {
|
||||
event: jest.Mock<void, [string, string, Record<string, number | string>]>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
call = jest.fn();
|
||||
logger = {
|
||||
event: jest.fn<void, [string, string, Record<string, number | string>]>(),
|
||||
};
|
||||
client = Object.assign(
|
||||
Object.create(TbankClient.prototype) as TbankClient,
|
||||
{ call, logger },
|
||||
);
|
||||
});
|
||||
|
||||
it('fetches the full share catalog through Shares with INSTRUMENT_STATUS_ALL', async () => {
|
||||
const instruments = [{ ticker: 'AFLT', uid: 'uid-aflt' }];
|
||||
call.mockResolvedValue({ instruments });
|
||||
|
||||
await expect(client.getAllShares(token)).resolves.toEqual(instruments);
|
||||
expect(call).toHaveBeenCalledWith('token', undefined, 'shares', {
|
||||
instrumentStatus: 'INSTRUMENT_STATUS_ALL',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns the whole catalog including a fitting instrument beyond the first 30 text matches', async () => {
|
||||
const catalog = Array.from({ length: catalogSize }, (_, index) => {
|
||||
const fitting = index === catalogSize - 1;
|
||||
return {
|
||||
apiTradeAvailableFlag: fitting,
|
||||
buyAvailableFlag: fitting,
|
||||
ticker: fitting ? 'FIT' : `T${index}`,
|
||||
uid: fitting ? 'uid-fit' : `uid-${index}`,
|
||||
};
|
||||
});
|
||||
call.mockResolvedValue({ instruments: catalog });
|
||||
|
||||
const shares = await client.getAllShares(token);
|
||||
|
||||
expect(shares).toEqual(catalog);
|
||||
expect(shares).toHaveLength(catalogSize);
|
||||
expect(shares).toContainEqual({
|
||||
apiTradeAvailableFlag: true,
|
||||
buyAvailableFlag: true,
|
||||
ticker: 'FIT',
|
||||
uid: 'uid-fit',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the source refuses instead of returning an empty success', async () => {
|
||||
const error = new ServiceUnavailableException({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
call.mockRejectedValue(error);
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBe(error);
|
||||
});
|
||||
|
||||
it('rejects when the response carries no instrument list', async () => {
|
||||
call.mockResolvedValue({});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an empty instrument list with the public unavailable code', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
const failure = await client
|
||||
.getAllShares(token)
|
||||
.catch((error: unknown) => error);
|
||||
|
||||
expect(failure).toBeInstanceOf(ServiceUnavailableException);
|
||||
expect((failure as ServiceUnavailableException).getResponse()).toEqual({
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a non-list instrument payload as uninterpretable', async () => {
|
||||
call.mockResolvedValue({ instruments: 'corrupted' });
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects an uninterpretable entry without instrument identity', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [
|
||||
{ ticker: 'FIT', uid: 'uid-fit' },
|
||||
{ ticker: 'BROKEN' },
|
||||
],
|
||||
});
|
||||
|
||||
await expect(client.getAllShares(token)).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
});
|
||||
|
||||
it('registers asOf and source instrument count on a successful catalog fetch', async () => {
|
||||
call.mockResolvedValue({
|
||||
instruments: [{ uid: 'uid-a' }, { uid: 'uid-b' }],
|
||||
});
|
||||
|
||||
await client.getAllShares(token);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'info',
|
||||
'tbank.shares.catalog.fetched',
|
||||
expect.objectContaining({ count: 2, operation: 'shares' }),
|
||||
);
|
||||
expect(logger.event.mock.calls[0][2].asOf).toEqual(expect.any(String));
|
||||
});
|
||||
|
||||
it('logs a distinguishable failure without secrets when the catalog is unusable', async () => {
|
||||
call.mockResolvedValue({ instruments: [] });
|
||||
|
||||
await expect(client.getAllShares('secret-token')).rejects.toBeInstanceOf(
|
||||
ServiceUnavailableException,
|
||||
);
|
||||
|
||||
expect(logger.event).toHaveBeenCalledWith(
|
||||
'error',
|
||||
'tbank.shares.catalog.unusable',
|
||||
expect.objectContaining({ operation: 'shares' }),
|
||||
);
|
||||
expect(JSON.stringify(logger.event.mock.calls)).not.toContain(
|
||||
'secret-token',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
ChannelCredentials,
|
||||
Client,
|
||||
ClientUnaryCall,
|
||||
connectivityState,
|
||||
loadPackageDefinition,
|
||||
Metadata,
|
||||
ServiceError,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
InstrumentIdType,
|
||||
TbankAccountStatus,
|
||||
TbankCandleInterval,
|
||||
TbankInstrumentStatus,
|
||||
TbankInstrumentType,
|
||||
TbankOperationState,
|
||||
} from './tbank-protocol';
|
||||
@@ -59,6 +61,13 @@ type Unary<Request, Response> = (
|
||||
callback: (error: null | ServiceError, response: Response) => void,
|
||||
) => ClientUnaryCall;
|
||||
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 TbankMoney = TbankDecimal & { currency?: string };
|
||||
export type TbankAccount = {
|
||||
@@ -325,6 +334,35 @@ export class TbankClient {
|
||||
).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[]> {
|
||||
return this.call<{ instruments?: TbankBond[] }>(
|
||||
token,
|
||||
@@ -337,18 +375,29 @@ export class TbankClient {
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
|
||||
getBond(token: string, uid: string): Promise<null | TbankBond> {
|
||||
getBond(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankBond> {
|
||||
return this.call<{ instrument?: TbankBond }>(
|
||||
token,
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: InstrumentIdType.Uid,
|
||||
idType,
|
||||
id: uid,
|
||||
},
|
||||
notFound,
|
||||
).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(
|
||||
token: string,
|
||||
uid: string,
|
||||
@@ -367,18 +416,25 @@ export class TbankClient {
|
||||
).then((response) => response.events ?? []);
|
||||
}
|
||||
|
||||
getShare(token: string, uid: string): Promise<null | TbankShare> {
|
||||
getShare(token: string, uid: string, idType = InstrumentIdType.Uid, notFound = false): Promise<null | TbankShare> {
|
||||
return this.call<{ instrument?: TbankShare }>(
|
||||
token,
|
||||
this.instruments,
|
||||
'shareBy',
|
||||
{
|
||||
idType: InstrumentIdType.Uid,
|
||||
idType,
|
||||
id: uid,
|
||||
},
|
||||
notFound,
|
||||
).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> {
|
||||
return this.call<{ asset?: { description?: string } }>(
|
||||
token,
|
||||
@@ -476,20 +532,29 @@ export class TbankClient {
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
notFound = false,
|
||||
): Promise<Response> {
|
||||
const metadataCall = method === 'getInstrumentBy';
|
||||
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||
const startedAt = performance.now();
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
const diagnostics: RpcDiagnostics = {};
|
||||
try {
|
||||
if (queueFull) {
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
);
|
||||
const response = await this.queue.run(() => {
|
||||
const sentAt = performance.now();
|
||||
diagnostics.queueWaitMs = Math.round(sentAt - startedAt);
|
||||
return this.request<Response>(
|
||||
token, client, method, request, diagnostics,
|
||||
).finally(() => {
|
||||
diagnostics.networkMs = Math.round(performance.now() - sentAt);
|
||||
});
|
||||
});
|
||||
this.logger.event('debug', 'tbank.rpc.completed', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
@@ -507,6 +572,7 @@ export class TbankClient {
|
||||
if (retryable && attempts < RPC_MAX_ATTEMPTS) {
|
||||
this.logger.event('warn', 'tbank.rpc.retry', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
...failureContext,
|
||||
attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
@@ -516,6 +582,7 @@ export class TbankClient {
|
||||
}
|
||||
this.logger.event('error', 'tbank.rpc.failed', {
|
||||
operation: method,
|
||||
...diagnostics,
|
||||
...failureContext,
|
||||
attempts: queueFull ? NO_RPC_ATTEMPTS : attempts,
|
||||
durationMs: Math.round(performance.now() - startedAt),
|
||||
@@ -528,7 +595,7 @@ export class TbankClient {
|
||||
}
|
||||
if (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
(method === 'shareBy' || metadataCall)
|
||||
(notFound || method === 'shareBy' || metadataCall)
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
@@ -543,17 +610,42 @@ export class TbankClient {
|
||||
client: TbankClientService,
|
||||
method: string,
|
||||
request: object,
|
||||
diagnostics?: RpcDiagnostics,
|
||||
): Promise<Response> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const metadata = new Metadata();
|
||||
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.
|
||||
client[method](
|
||||
const call = client[method](
|
||||
request as never,
|
||||
metadata,
|
||||
{ deadline: new Date(Date.now() + requestDeadlineMs) },
|
||||
(error, response) => (error ? reject(error) : resolve(response)),
|
||||
(error, response) => {
|
||||
if (diagnostics && channel) {
|
||||
diagnostics.channelState = connectivityState[
|
||||
channel.getConnectivityState(false)
|
||||
];
|
||||
}
|
||||
if (error) reject(error);
|
||||
else resolve(response);
|
||||
},
|
||||
);
|
||||
if (diagnostics) {
|
||||
call.on('metadata', (headers: Metadata) => {
|
||||
diagnostics.headersReceived = true;
|
||||
const trackingId = headers.get('x-tracking-id')[0];
|
||||
// Only a UUID may enter logs; never forward arbitrary upstream metadata.
|
||||
if (
|
||||
typeof trackingId === 'string' &&
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(trackingId)
|
||||
) {
|
||||
diagnostics.trackingId = trackingId;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ export function configureApp(app: INestApplication): void {
|
||||
exceptionFactory: (message) => new BadRequestException({ message }),
|
||||
}),
|
||||
);
|
||||
app.useGlobalFilters(new HttpExceptionFilter());
|
||||
app.useGlobalFilters(new HttpExceptionFilter(app.get(StructuredLogger)));
|
||||
app.enableShutdownHooks();
|
||||
}
|
||||
|
||||
|
||||
@@ -2,12 +2,14 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||
compare: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
@@ -51,7 +53,7 @@ describe('AuthService', () => {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = new AuthService(prisma as never, jwt);
|
||||
const service = new AuthService(new AuthRepository(prisma as never), jwt);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -7,17 +7,16 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import { Session, User } from '../../../../generated/prisma/client';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import type { Session, User } from '#generated/prisma/client';
|
||||
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||
import { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
const PASSWORD_HASH_ROUNDS = 12;
|
||||
const REFRESH_TTL_MS = 604_800_000;
|
||||
export const REFRESH_TTL_MS = 604_800_000;
|
||||
|
||||
const ACCESS_TTL = '15m';
|
||||
const REFRESH_TTL = '7d';
|
||||
@@ -28,7 +27,7 @@ const DUMMY_PASSWORD_HASH =
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: AuthRepository,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
@@ -36,27 +35,18 @@ export class AuthService {
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthenticationResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
const existing = await this.repository.findUserByEmail(email);
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
|
||||
let user: User;
|
||||
try {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
throw error;
|
||||
const user = await this.repository.createUser({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
});
|
||||
if (!user) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
@@ -65,9 +55,7 @@ export class AuthService {
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthenticationResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
const user = await this.repository.findUserByEmail(email);
|
||||
const valid = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
@@ -92,10 +80,7 @@ export class AuthService {
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
include: { user: true },
|
||||
});
|
||||
const session = await this.repository.findSessionWithUser(payload.sid);
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
@@ -103,7 +88,7 @@ export class AuthService {
|
||||
session.expiresAt <= new Date()
|
||||
) {
|
||||
if (session && !session.revokedAt) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
await this.repository.deleteSession(session.id);
|
||||
}
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
@@ -124,18 +109,13 @@ export class AuthService {
|
||||
return;
|
||||
}
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
const session = await this.repository.findSession(payload.sid);
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.repository.revokeSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +124,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
const user = await this.repository.findUserById(id);
|
||||
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
return user;
|
||||
}
|
||||
@@ -152,14 +132,7 @@ export class AuthService {
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<AuthenticationResult> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
refreshTokenHash: '',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
const session = await this.repository.createSession(user.id, randomUUID());
|
||||
return this.rotateSession({ ...session, user });
|
||||
}
|
||||
|
||||
@@ -180,16 +153,8 @@ export class AuthService {
|
||||
refreshTokenHash: this.hash(refreshToken),
|
||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||
};
|
||||
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 });
|
||||
}
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return {
|
||||
user: this.toUser(session.user),
|
||||
@@ -205,12 +170,3 @@ export class AuthService {
|
||||
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)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { AuthService } from './application/auth.service';
|
||||
import { AuthRepository } from './infrastructure/auth.repository';
|
||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||
import { AuthController } from './presentation/auth.controller';
|
||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
],
|
||||
exports: [JwtAuthGuard],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
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,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
import { Environment } from '#src/config/env.validation';
|
||||
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
import { JwtAuthGuard } from './jwt-auth.guard';
|
||||
|
||||
const REFRESH_COOKIE = 'refresh_token';
|
||||
@@ -56,7 +57,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async register(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: RegisterDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
@@ -69,7 +70,7 @@ export class AuthController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOkResponse({ type: AuthenticationDto })
|
||||
async login(
|
||||
@Body() credentials: CredentialsDto,
|
||||
@Body() credentials: LoginDto,
|
||||
@Res({ passthrough: true }) response: Response,
|
||||
) {
|
||||
return this.respond(
|
||||
@@ -134,6 +135,7 @@ export class AuthController {
|
||||
sameSite: 'lax' as const,
|
||||
secure: process.env['NODE_ENV'] !== 'development',
|
||||
path: COOKIE_PATH,
|
||||
maxAge: REFRESH_TTL_MS,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import {
|
||||
IsNotEmpty,
|
||||
IsString,
|
||||
MinLength,
|
||||
Matches,
|
||||
Validate,
|
||||
ValidatorConstraint,
|
||||
} from 'class-validator';
|
||||
@@ -13,8 +14,10 @@ const FIRST_CHARACTER_INDEX = 0;
|
||||
const MAX_EMAIL_LENGTH = 254;
|
||||
const ASCII_SPACE = 32;
|
||||
const ASCII_DELETE = 127;
|
||||
const MIN_PASSWORD_LENGTH = 12;
|
||||
// bcrypt reads at most 72 bytes; printable ASCII keeps characters == bytes.
|
||||
const MIN_PASSWORD_LENGTH = 8;
|
||||
const MAX_PASSWORD_BYTES = 72;
|
||||
const PASSWORD_PATTERN = `^[\\x20-\\x7E]{${MIN_PASSWORD_LENGTH},${MAX_PASSWORD_BYTES}}$`;
|
||||
|
||||
@ValidatorConstraint({ name: 'email' })
|
||||
class EmailConstraint {
|
||||
@@ -40,8 +43,9 @@ class EmailConstraint {
|
||||
}
|
||||
}
|
||||
|
||||
@ValidatorConstraint({ name: 'passwordBytes' })
|
||||
class PasswordBytesConstraint {
|
||||
// Login stays lenient: accounts created under an older policy must still sign in.
|
||||
@ValidatorConstraint({ name: 'passwordMaxBytes' })
|
||||
class PasswordMaxBytesConstraint {
|
||||
validate(value: unknown): boolean {
|
||||
return (
|
||||
typeof value === 'string' &&
|
||||
@@ -50,11 +54,11 @@ class PasswordBytesConstraint {
|
||||
}
|
||||
|
||||
defaultMessage(): string {
|
||||
return 'must not exceed 72 UTF-8 bytes';
|
||||
return `must not exceed ${MAX_PASSWORD_BYTES} UTF-8 bytes`;
|
||||
}
|
||||
}
|
||||
|
||||
export class CredentialsDto {
|
||||
class EmailDto {
|
||||
@ApiProperty({ example: 'user@example.com' })
|
||||
@IsString()
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
@@ -62,14 +66,28 @@ export class CredentialsDto {
|
||||
)
|
||||
@Validate(EmailConstraint)
|
||||
declare email: string;
|
||||
}
|
||||
|
||||
export class RegisterDto extends EmailDto {
|
||||
@ApiProperty({
|
||||
minLength: MIN_PASSWORD_LENGTH,
|
||||
maxLength: MAX_PASSWORD_BYTES,
|
||||
description: `Raw password: ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters (letters, digits, symbols, space). Not trimmed or normalized.`,
|
||||
pattern: PASSWORD_PATTERN,
|
||||
example: 'a secure password',
|
||||
})
|
||||
@IsString()
|
||||
@MinLength(MIN_PASSWORD_LENGTH)
|
||||
@Validate(PasswordBytesConstraint)
|
||||
@Matches(new RegExp(PASSWORD_PATTERN), {
|
||||
message: `must be ${MIN_PASSWORD_LENGTH}-${MAX_PASSWORD_BYTES} printable ASCII characters`,
|
||||
})
|
||||
declare password: string;
|
||||
}
|
||||
|
||||
export class LoginDto extends EmailDto {
|
||||
@ApiProperty({
|
||||
description: `Raw password, at most ${MAX_PASSWORD_BYTES} UTF-8 bytes. Not trimmed or normalized.`,
|
||||
example: 'a secure password',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@Validate(PasswordMaxBytesConstraint)
|
||||
declare password: string;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import type { BankCard, Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
@@ -20,24 +20,13 @@ import {
|
||||
missing,
|
||||
} from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_CARDS = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankCardsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||
const rows = await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: card.userId,
|
||||
deletedAt: null,
|
||||
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
||||
},
|
||||
});
|
||||
const rows = await this.repository.cardOperations(tx, card);
|
||||
return {
|
||||
id: card.id,
|
||||
name: card.name,
|
||||
@@ -53,20 +42,15 @@ export class BankCardsService {
|
||||
}
|
||||
|
||||
list(userId: string, includeArchived = false) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
Promise.all(
|
||||
(
|
||||
await tx.bankCard.findMany({
|
||||
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||
})
|
||||
).map((card) => this.cardView(tx, card)),
|
||||
),
|
||||
return this.repository.read(async (tx) =>
|
||||
Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
|
||||
(card) => this.cardView(tx, card),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
get(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) =>
|
||||
return this.repository.read(async (tx) =>
|
||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||
);
|
||||
}
|
||||
@@ -75,7 +59,7 @@ export class BankCardsService {
|
||||
const input = cardInput(dto),
|
||||
hash = fingerprint(input);
|
||||
return this.repository.write(async (tx) => {
|
||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
||||
const existing = await this.repository.cardById(tx, dto.id);
|
||||
if (existing) {
|
||||
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.
|
||||
@@ -88,8 +72,11 @@ export class BankCardsService {
|
||||
await this.repository.seed(tx, userId);
|
||||
return this.cardView(
|
||||
tx,
|
||||
await tx.bankCard.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
await this.repository.createCard(tx, {
|
||||
...input,
|
||||
id: dto.id,
|
||||
userId,
|
||||
createFingerprint: hash,
|
||||
}),
|
||||
);
|
||||
});
|
||||
@@ -122,20 +109,16 @@ export class BankCardsService {
|
||||
}
|
||||
currentDate(input.openingDate);
|
||||
if (
|
||||
await tx.bankCardOperation.findFirst({
|
||||
where: {
|
||||
userId,
|
||||
deletedAt: null,
|
||||
date: { lt: input.openingDate },
|
||||
OR: [{ cardId: id }, { targetCardId: id }],
|
||||
},
|
||||
})
|
||||
await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
|
||||
) {
|
||||
fail(BankErrorCode.OpeningDateConflict);
|
||||
}
|
||||
const changed = await tx.bankCard.updateMany({
|
||||
where: { id, userId, version: dto.version },
|
||||
data: {
|
||||
const updated = await this.repository.updateCard(
|
||||
tx,
|
||||
userId,
|
||||
id,
|
||||
dto.version,
|
||||
{
|
||||
...input,
|
||||
paymentSystem: input.paymentSystem ?? null,
|
||||
version: { increment: 1 },
|
||||
@@ -147,9 +130,8 @@ export class BankCardsService {
|
||||
: null,
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
||||
);
|
||||
return this.cardView(tx, updated);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { GoneException, Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
import type {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import {
|
||||
operationFieldConflict,
|
||||
operationKindChanged,
|
||||
} from '../domain/bank-operation.rules';
|
||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import {
|
||||
@@ -28,67 +32,67 @@ import {
|
||||
periodBounds,
|
||||
} from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_ROWS = 1;
|
||||
|
||||
type Tx = Prisma.TransactionClient;
|
||||
@Injectable()
|
||||
export class BankOperationsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
private async operationView(
|
||||
tx: Tx,
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId
|
||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await tx.expenseCategory.findFirst({
|
||||
where: { id: categoryId, userId: row.userId },
|
||||
})
|
||||
: null;
|
||||
const refunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? await tx.bankCardOperation.findMany({
|
||||
where: {
|
||||
userId: row.userId,
|
||||
originalExpenseId: source?.id ?? row.id,
|
||||
deletedAt: null,
|
||||
...(source ? { id: { not: row.id } } : {}),
|
||||
},
|
||||
})
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: category?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
return (await this.pageViews(tx, [row]))[0];
|
||||
}
|
||||
|
||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||
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
|
||||
? sourcesById.get(row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const relatedRefunds =
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
||||
? refunds.filter(
|
||||
(refund) =>
|
||||
refund.originalExpenseId === (source?.id ?? row.id) &&
|
||||
(!source || refund.id !== row.id),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
: [];
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
cardId: row.cardId,
|
||||
cardName: card.name,
|
||||
targetCardId: row.targetCardId,
|
||||
targetCardName: target?.name ?? null,
|
||||
categoryId,
|
||||
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
|
||||
originalExpenseId: row.originalExpenseId,
|
||||
amount: rubMoney(row.amountKopecks),
|
||||
availableRefund:
|
||||
row.kind === BankOperationKind.Expense || source
|
||||
? rubMoney(
|
||||
(source?.amountKopecks ?? row.amountKopecks) -
|
||||
relatedRefunds.reduce(
|
||||
(sum, refund) => sum + refund.amountKopecks,
|
||||
ZERO_KOPECKS,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
date: row.date,
|
||||
description: row.description,
|
||||
version: row.version,
|
||||
createdAt: row.createdAt.toISOString(),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private async validateOperation(
|
||||
@@ -112,49 +116,28 @@ export class BankOperationsService {
|
||||
fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
}
|
||||
if (
|
||||
old &&
|
||||
(old.kind !== input.kind ||
|
||||
old.originalExpenseId !== input.originalExpenseId)
|
||||
) {
|
||||
if (old && operationKindChanged(old.kind, input.kind,
|
||||
old.originalExpenseId, input.originalExpenseId)) {
|
||||
fail(BankErrorCode.ImmutableOperation);
|
||||
}
|
||||
if (input.kind === BankOperationKind.Transfer) {
|
||||
if (
|
||||
!input.targetCardId ||
|
||||
input.targetCardId === input.cardId ||
|
||||
input.categoryId ||
|
||||
input.originalExpenseId
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
} else if (input.targetCardId) fail();
|
||||
if (operationFieldConflict(input)) fail();
|
||||
if (input.kind === BankOperationKind.Expense) {
|
||||
if (input.originalExpenseId) fail();
|
||||
if (!input.categoryId) {
|
||||
const category = await tx.expenseCategory.findUnique({
|
||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||
});
|
||||
const category = await this.repository.defaultCategory(tx, userId);
|
||||
if (!category) missing();
|
||||
input.categoryId = category.id;
|
||||
}
|
||||
if (
|
||||
!(await tx.expenseCategory.findFirst({
|
||||
where: { userId, id: input.categoryId },
|
||||
}))
|
||||
!(await this.repository.category(tx, userId, input.categoryId))
|
||||
) {
|
||||
missing();
|
||||
}
|
||||
} else if (input.categoryId) fail();
|
||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
||||
fail();
|
||||
}
|
||||
if (input.kind === BankOperationKind.Refund) {
|
||||
if (!input.originalExpenseId) fail();
|
||||
const source = await this.repository.operation(
|
||||
tx,
|
||||
userId,
|
||||
input.originalExpenseId,
|
||||
input.originalExpenseId as string,
|
||||
);
|
||||
if (
|
||||
source.deletedAt ||
|
||||
@@ -163,29 +146,11 @@ export class BankOperationsService {
|
||||
) {
|
||||
fail(BankErrorCode.InvalidRefund);
|
||||
}
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: {
|
||||
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 } } : {}),
|
||||
},
|
||||
});
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id: source.id, userId, version: source.version, deletedAt: null },
|
||||
{ version: { increment: 1 } },
|
||||
);
|
||||
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
|
||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||
if (
|
||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||
@@ -196,9 +161,7 @@ export class BankOperationsService {
|
||||
}
|
||||
}
|
||||
if (old?.kind === BankOperationKind.Expense) {
|
||||
const refunds = await tx.bankCardOperation.findMany({
|
||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
||||
});
|
||||
const refunds = await this.repository.refunds(tx, userId, old.id);
|
||||
if (
|
||||
refunds.length &&
|
||||
(input.cardId !== old.cardId ||
|
||||
@@ -215,9 +178,7 @@ export class BankOperationsService {
|
||||
const normalized = operationInput(dto),
|
||||
hash = fingerprint(normalized);
|
||||
return this.repository.write(async (tx) => {
|
||||
const existing = await tx.bankCardOperation.findUnique({
|
||||
where: { id: dto.id },
|
||||
});
|
||||
const existing = await this.repository.operationById(tx, dto.id);
|
||||
if (existing) {
|
||||
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.
|
||||
@@ -233,15 +194,15 @@ export class BankOperationsService {
|
||||
await this.validateOperation(tx, userId, input);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await tx.bankCardOperation.create({
|
||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
||||
await this.repository.createOperation(tx, {
|
||||
...input, id: dto.id, userId, createFingerprint: hash,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
getOperation(userId: string, id: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.repository.read(async (tx) => {
|
||||
const row = await this.repository.operation(tx, userId, id);
|
||||
if (row.deletedAt) missing();
|
||||
return this.operationView(tx, row);
|
||||
@@ -266,16 +227,10 @@ export class BankOperationsService {
|
||||
description: dto.description ?? old.description ?? undefined,
|
||||
});
|
||||
await this.validateOperation(tx, userId, input, old);
|
||||
if (
|
||||
(
|
||||
await tx.bankCardOperation.updateMany({
|
||||
where: { id, userId, version: dto.version, deletedAt: null },
|
||||
data: { ...input, version: { increment: 1 } },
|
||||
})
|
||||
).count !== EXPECTED_UPDATED_ROWS
|
||||
) {
|
||||
conflict();
|
||||
}
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id, userId, version: dto.version, deletedAt: null },
|
||||
{ ...input, version: { increment: 1 } },
|
||||
);
|
||||
return this.operationView(
|
||||
tx,
|
||||
await this.repository.operation(tx, userId, id),
|
||||
@@ -294,9 +249,7 @@ export class BankOperationsService {
|
||||
}
|
||||
if (
|
||||
row.kind === BankOperationKind.Expense &&
|
||||
(await tx.bankCardOperation.findFirst({
|
||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
||||
}))
|
||||
(await this.repository.dependentRefund(tx, userId, id))
|
||||
) {
|
||||
conflict(BankErrorCode.DependentRefunds);
|
||||
}
|
||||
@@ -306,27 +259,15 @@ export class BankOperationsService {
|
||||
userId,
|
||||
row.originalExpenseId,
|
||||
);
|
||||
if (
|
||||
(
|
||||
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: source.id, userId, version: source.version },
|
||||
{ version: { increment: 1 } },
|
||||
);
|
||||
}
|
||||
await this.repository.updateOperation(tx,
|
||||
{ id, userId, version, deletedAt: null },
|
||||
{ deletedAt: new Date(), version: { increment: 1 } },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -339,29 +280,15 @@ export class BankOperationsService {
|
||||
const period = expenses
|
||||
? undefined
|
||||
: periodBounds(query as BankOperationsQueryDto);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
return this.repository.read(async (tx) => {
|
||||
await this.repository.card(tx, userId, id);
|
||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||
page = query.page ?? FIRST_PAGE;
|
||||
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,
|
||||
});
|
||||
const { total, rows } = await this.repository.operationPage(
|
||||
tx, userId, id, period, limit, page,
|
||||
);
|
||||
return {
|
||||
items: await Promise.all(
|
||||
rows.map((row) => this.operationView(tx, row)),
|
||||
),
|
||||
items: await this.pageViews(tx, rows),
|
||||
total,
|
||||
page,
|
||||
hasNext: page * limit < total,
|
||||
|
||||
@@ -1,81 +1,24 @@
|
||||
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 { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { conflict, missing, name } from './bank-card-input';
|
||||
|
||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||
const VERSION_INCREMENT = 1;
|
||||
import { name } from './bank-card-input';
|
||||
|
||||
@Injectable()
|
||||
export class ExpenseCategoriesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: BankCardsRepository,
|
||||
) {}
|
||||
constructor(private readonly repository: BankCardsRepository) {}
|
||||
|
||||
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,
|
||||
}));
|
||||
return this.repository.categories(userId);
|
||||
}
|
||||
|
||||
createCategory(userId: string, value: string) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
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,
|
||||
};
|
||||
});
|
||||
return this.repository.createCategory(userId, label);
|
||||
}
|
||||
|
||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||
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,
|
||||
};
|
||||
});
|
||||
return this.repository.updateCategory(userId, id, label, version);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
import { ZERO_KOPECKS } from './rub-money';
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
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 '../../../../common/money.types';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||
|
||||
export const ZERO_KOPECKS = 0n;
|
||||
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
missing,
|
||||
} from '../application/bank-card-input';
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
|
||||
const INITIAL_RETRY_COUNT = 0;
|
||||
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;
|
||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||
@@ -32,6 +38,220 @@ const seeds = [
|
||||
@Injectable()
|
||||
export class BankCardsRepository {
|
||||
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> {
|
||||
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
||||
try {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
getSchemaPath,
|
||||
IntersectionType,
|
||||
OmitType,
|
||||
PartialType,
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
FIRST_PAGE,
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
@@ -153,6 +155,49 @@ export class CreateBankOperationDto {
|
||||
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||
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(
|
||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||
{ skipNullProperties: false },
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiResponse,
|
||||
@@ -27,8 +29,9 @@ import {
|
||||
import {
|
||||
ApiProblemResponses,
|
||||
ProblemDetailsDto,
|
||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
} from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BankCardsService } from '../application/bank-cards.service';
|
||||
import { BankOperationsService } from '../application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||
@@ -46,6 +49,11 @@ import {
|
||||
CategoryNameDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
createBankOperationSchema,
|
||||
CreateExpenseOperationDto,
|
||||
CreateRefundOperationDto,
|
||||
CreateTopupOperationDto,
|
||||
CreateTransferOperationDto,
|
||||
ExpenseCategoryDto,
|
||||
UpdateBankCardDto,
|
||||
UpdateBankOperationDto,
|
||||
@@ -73,6 +81,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
||||
},
|
||||
})
|
||||
@ApiTags('bank-cards')
|
||||
@ApiExtraModels(
|
||||
CreateExpenseOperationDto,
|
||||
CreateTopupOperationDto,
|
||||
CreateTransferOperationDto,
|
||||
CreateRefundOperationDto,
|
||||
)
|
||||
@ApiBearerAuth()
|
||||
@ApiProblemResponses()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -131,6 +145,7 @@ export class BankCardsController {
|
||||
}
|
||||
|
||||
@Post('operations')
|
||||
@ApiBody({ schema: createBankOperationSchema })
|
||||
@ApiCreatedResponse({ type: BankOperationDto })
|
||||
createOperation(
|
||||
@Req() req: AuthRequest,
|
||||
|
||||
@@ -1,12 +1,41 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||
|
||||
export class UnrealizedReturnDto {
|
||||
@ApiProperty()
|
||||
declare currency: string;
|
||||
|
||||
@ApiProperty({ example: '12.345678901' })
|
||||
declare absolute: string;
|
||||
|
||||
@ApiProperty({ example: '100.00' })
|
||||
declare costBasis: string;
|
||||
|
||||
@ApiProperty({ type: String, nullable: true, example: '12.34' })
|
||||
declare percent: null | string;
|
||||
|
||||
@ApiProperty()
|
||||
declare partial: boolean;
|
||||
}
|
||||
|
||||
export class PortfolioSummaryDto {
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare totalsByCurrency: MoneyDto[];
|
||||
|
||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
||||
declare cashBalances: MoneyDto[];
|
||||
|
||||
@ApiProperty({ type: PortfolioStructureDto })
|
||||
declare structure: PortfolioStructureDto;
|
||||
|
||||
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
||||
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
||||
|
||||
@ApiProperty({
|
||||
type: 'integer',
|
||||
minimum: 0,
|
||||
@@ -20,6 +49,20 @@ export class PortfolioSummaryDto {
|
||||
description: 'Number of non-zero bond positions, not the number of units.',
|
||||
})
|
||||
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 {
|
||||
@@ -55,4 +98,12 @@ export class BrokerageAccountsResourceDto {
|
||||
|
||||
@ApiProperty({ type: InvestmentResourceMetaDto })
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
|
||||
@ApiProperty({
|
||||
type: HourlySyncStatusDto,
|
||||
nullable: true,
|
||||
description:
|
||||
'Read-only status of the last hourly walk for the current connection. Null when no walk attempt exists yet.',
|
||||
})
|
||||
declare sync: HourlySyncStatusDto | null;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@@ -19,10 +19,7 @@ export class BrokerageAccountsController {
|
||||
|
||||
@Get()
|
||||
@ApiOkResponse({ type: BrokerageAccountsResourceDto })
|
||||
list(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.accounts.list(request.user.sub, query.sync === 'observe');
|
||||
list(@Req() request: Request & { user: AccessPayload }) {
|
||||
return this.accounts.list(request.user.sub);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageAccountsController } from './brokerage-accounts.controller';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, TbankModule, PortfolioModule],
|
||||
imports: [AuthModule, TbankModule, PortfolioModule, TbankSyncRepositoryModule],
|
||||
controllers: [BrokerageAccountsController],
|
||||
providers: [BrokerageAccountsService],
|
||||
exports: [BrokerageAccountsService],
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
@@ -19,19 +19,21 @@ const meta = investmentResourceMeta({
|
||||
|
||||
describe('saved brokerage account projection', () => {
|
||||
const portfolio = { saved: jest.fn() };
|
||||
const service = new BrokerageAccountsService(portfolio as never);
|
||||
const sync = { hourly: jest.fn().mockResolvedValue(null) };
|
||||
const service = new BrokerageAccountsService(portfolio as never, sync as never);
|
||||
|
||||
it('distinguishes unknown membership from a confirmed empty list', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: { ...meta, availability: 'missing' },
|
||||
membership: { ...meta, completeness: 'incomplete' },
|
||||
accounts: [],
|
||||
});
|
||||
expect((await service.list('owner', true)).data).toBeNull();
|
||||
expect((await service.list('owner')).data).toBeNull();
|
||||
portfolio.saved.mockResolvedValueOnce({ membership: meta, accounts: [] });
|
||||
expect((await service.list('owner', true)).data).toEqual([]);
|
||||
expect((await service.list('owner')).data).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||
const structure = { currencies: [], unvaluedCategories: [] };
|
||||
portfolio.saved.mockResolvedValue({
|
||||
membership: meta,
|
||||
accounts: [
|
||||
@@ -42,10 +44,12 @@ describe('saved brokerage account projection', () => {
|
||||
openedOn: null,
|
||||
meta,
|
||||
snapshot: {
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
structure,
|
||||
totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100000000.000000001' },
|
||||
],
|
||||
positions: [{ type: 'STOCK' }, { type: 'BOND' }],
|
||||
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -58,13 +62,122 @@ describe('saved brokerage account projection', () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const response = await service.list('owner', true);
|
||||
const response = await service.list('owner');
|
||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
||||
structure,
|
||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
||||
stockPositionCount: 1,
|
||||
bondPositionCount: 1,
|
||||
fundPositionCount: 0,
|
||||
totalPositionCount: 2,
|
||||
unrealizedReturnByCurrency: [],
|
||||
});
|
||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||
expect(response.meta.availability).toBe('partial');
|
||||
expect(response.meta.availability).toBe('available');
|
||||
expect(response.meta.completeness).toBe('complete');
|
||||
});
|
||||
|
||||
it('returns an empty summary from an empty saved portfolio', async () => {
|
||||
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
||||
cashBalances: [], structure, totalsByCurrency: [],
|
||||
stockPositionCount: 0, bondPositionCount: 0,
|
||||
fundPositionCount: 0, totalPositionCount: 0,
|
||||
unrealizedReturnByCurrency: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [
|
||||
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
||||
],
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
||||
{ type: 'BOND', quantity: '0.5', averagePurchasePrice: { currency: 'RUB', value: '20' }, absoluteReturn: { currency: 'RUB', value: '1.25' } },
|
||||
{ type: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'USD', value: '2.5' }, absoluteReturn: null },
|
||||
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
||||
{ type: 'CURRENCY', quantity: '100', averagePurchasePrice: { currency: 'EUR', value: '1' }, absoluteReturn: { currency: 'EUR', value: '5' } },
|
||||
{ type: 'BOND', quantity: '1', currentValue: { currency: 'CNY', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||
stockPositionCount: 2, bondPositionCount: 2,
|
||||
fundPositionCount: 1, totalPositionCount: 6,
|
||||
unrealizedReturnByCurrency: [
|
||||
{ currency: 'CNY', absolute: '0', costBasis: '0', percent: null, partial: true },
|
||||
{ currency: 'RUB', absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: false },
|
||||
{ currency: 'USD', absolute: '2.5', costBasis: '0', percent: null, partial: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('omits return percent for a currency with short positions', async () => {
|
||||
portfolio.saved.mockResolvedValueOnce({
|
||||
membership: meta,
|
||||
accounts: [{
|
||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||
snapshot: {
|
||||
cashBalances: [], structure: {}, totalsByCurrency: [],
|
||||
positions: [
|
||||
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
||||
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'USD', value: '10' }, absoluteReturn: { currency: 'USD', value: '1' } },
|
||||
],
|
||||
},
|
||||
}],
|
||||
});
|
||||
|
||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
|
||||
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
||||
{ currency: 'USD', absolute: '1', costBasis: '10', percent: '10', partial: false },
|
||||
]);
|
||||
});
|
||||
|
||||
it('observes the latest hourly walk without starting another one', async () => {
|
||||
portfolio.saved.mockResolvedValue({ membership: meta, accounts: [] });
|
||||
sync.hourly.mockResolvedValue({
|
||||
startedAt: new Date('2026-09-25T12:00:00Z'),
|
||||
finishedAt: new Date('2026-09-25T12:01:00Z'),
|
||||
state: 'partial',
|
||||
sections: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const response = await service.list('owner');
|
||||
|
||||
expect(response.sync).toEqual({
|
||||
startedAt: '2026-09-25T12:00:00.000Z',
|
||||
finishedAt: '2026-09-25T12:01:00.000Z',
|
||||
state: 'partial',
|
||||
problems: [
|
||||
{
|
||||
section: 'portfolio',
|
||||
accountId: 'a',
|
||||
errorCode: 'TBANK_UNAVAILABLE',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(sync.hourly).toHaveBeenCalledWith('owner');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,18 +1,95 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from '#src/common/decimal';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { BrokerageAccountDto } from './brokerage-account.dto';
|
||||
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
||||
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
||||
import {
|
||||
BrokerageAccountDto,
|
||||
BrokerageAccountsResourceDto,
|
||||
UnrealizedReturnDto,
|
||||
} from './brokerage-account.dto';
|
||||
|
||||
const PERCENT_DECIMAL_PLACES = 2;
|
||||
const ZERO_QUANTITY = 0n;
|
||||
|
||||
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
||||
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
||||
const investments = positions.filter((position) => position.type !== 'CURRENCY');
|
||||
const absolute = sumMoneyByCurrency(
|
||||
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
|
||||
);
|
||||
const costs = sumMoneyByCurrency(
|
||||
investments.flatMap((position) =>
|
||||
position.absoluteReturn && position.averagePurchasePrice
|
||||
? [{
|
||||
currency: position.absoluteReturn.currency,
|
||||
value: formatDecimal(multiply(
|
||||
decimalFromString(position.averagePurchasePrice.value),
|
||||
decimalFromString(position.quantity),
|
||||
)),
|
||||
}]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
const partial = new Set(
|
||||
investments.flatMap((position) => {
|
||||
const money = position.absoluteReturn
|
||||
? null
|
||||
: position.averagePurchasePrice ?? position.currentValue;
|
||||
return money ? [money.currency] : [];
|
||||
}),
|
||||
);
|
||||
// A short position has negative cost basis, so a percent would flip sign or net out.
|
||||
const withShorts = new Set(
|
||||
investments.flatMap((position) => {
|
||||
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
||||
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
||||
}),
|
||||
);
|
||||
const currencies = new Set([...absolute, ...costs].map((money) => money.currency));
|
||||
return [...new Set([...currencies, ...partial])]
|
||||
.sort((left, right) => left.localeCompare(right))
|
||||
.map((currency) => {
|
||||
const absoluteReturn = absolute.find((money) => money.currency === currency)?.value ?? '0';
|
||||
const costBasis = costs.find((money) => money.currency === currency)?.value ?? '0';
|
||||
const cost = decimalFromString(costBasis);
|
||||
return {
|
||||
currency,
|
||||
absolute: absoluteReturn,
|
||||
costBasis,
|
||||
percent: isZero(cost) || withShorts.has(currency)
|
||||
? null
|
||||
: formatDecimal(divide(
|
||||
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
||||
cost,
|
||||
PERCENT_DECIMAL_PLACES,
|
||||
)),
|
||||
partial: partial.has(currency),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageAccountsService {
|
||||
constructor(private readonly portfolio: PortfolioService) {}
|
||||
constructor(
|
||||
private readonly portfolio: PortfolioService,
|
||||
private readonly sync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
async list(userId: string, observe = false) {
|
||||
const saved = await this.portfolio.saved(userId, 'all', observe);
|
||||
async list(userId: string): Promise<BrokerageAccountsResourceDto> {
|
||||
const saved = await this.portfolio.saved(userId, 'all');
|
||||
const data: BrokerageAccountDto[] | null =
|
||||
saved.membership.availability === 'missing'
|
||||
saved.membership.completeness === 'incomplete'
|
||||
? null
|
||||
: saved.accounts.map((account) => ({
|
||||
id: account.id,
|
||||
@@ -22,15 +99,25 @@ export class BrokerageAccountsService {
|
||||
portfolioSummary: account.snapshot
|
||||
? {
|
||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
||||
cashBalances: account.snapshot.cashBalances,
|
||||
structure: account.snapshot.structure,
|
||||
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
||||
stockPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'STOCK',
|
||||
).length,
|
||||
bondPositionCount: account.snapshot.positions.filter(
|
||||
(position) => position.type === 'BOND',
|
||||
).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,
|
||||
}));
|
||||
// The read-only result of the last hourly walk; never starts one.
|
||||
const hourly = await this.sync.hourly(userId);
|
||||
return {
|
||||
data,
|
||||
meta: investmentResourceMeta({
|
||||
@@ -49,18 +136,26 @@ export class BrokerageAccountsService {
|
||||
availability:
|
||||
data === null
|
||||
? 'missing'
|
||||
: saved.accounts.some((account) => !account.snapshot)
|
||||
? 'partial'
|
||||
: 'complete',
|
||||
: 'complete',
|
||||
fetchedAt: saved.membership.lastSuccessfulAt
|
||||
? new Date(saved.membership.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: 300_000,
|
||||
work: saved.membership.sync.id
|
||||
? { ...saved.membership.sync, id: saved.membership.sync.id }
|
||||
: null,
|
||||
freshForMs: 3_600_000,
|
||||
work: null,
|
||||
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
|
||||
dependencies: saved.accounts.map((account) => account.meta),
|
||||
}),
|
||||
sync: hourly
|
||||
? {
|
||||
state: hourly.state,
|
||||
startedAt: hourly.startedAt.toISOString(),
|
||||
finishedAt: hourly.finishedAt?.toISOString() ?? null,
|
||||
problems: hourly.sections.map((section) => ({
|
||||
...section,
|
||||
accountId: section.accountId ?? null,
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import {
|
||||
IncomeAnalyticsQueryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
|
||||
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||
allSummary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
'all',
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
|
||||
'all',
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
|
||||
summary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
accountId,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
|
||||
accountId,
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
InvestmentDependencyDto,
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||
export class IncomeAnalyticsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description:
|
||||
@@ -67,28 +67,6 @@ export class IncomeSummaryDto {
|
||||
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 {
|
||||
@ApiProperty({ type: IncomePeriodDto })
|
||||
declare period: IncomePeriodDto;
|
||||
@@ -135,12 +113,6 @@ export class IncomeAnalyticsEntryDto extends BrokerageOperationDto {
|
||||
}
|
||||
|
||||
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'] })
|
||||
declare bucketGranularity: 'day' | 'month' | 'week';
|
||||
|
||||
@@ -162,7 +134,7 @@ export class IncomeSummaryResourceDto {
|
||||
declare meta: InvestmentResourceMetaDto;
|
||||
}
|
||||
|
||||
const incomeDependencyRoles = ['selected', 'previous', 'year'] as const;
|
||||
const incomeDependencyRoles = ['selected'] as const;
|
||||
|
||||
export class IncomeDependencyDto extends InvestmentDependencyDto {
|
||||
@ApiProperty({ enum: incomeDependencyRoles })
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||
@@ -17,7 +17,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('labels each comparison dependency independently of its resource key', async () => {
|
||||
it('labels the selected dependency independently of its resource key', async () => {
|
||||
seed([]);
|
||||
const response = await service.analytics(
|
||||
'user',
|
||||
@@ -27,32 +27,10 @@ describe('IncomeAnalyticsService', () => {
|
||||
);
|
||||
expect(response.meta.dependencies).toEqual([
|
||||
expect.objectContaining({ role: 'selected', resourceKey: 'period/0' }),
|
||||
expect.objectContaining({ role: 'previous', resourceKey: 'period/1' }),
|
||||
expect.objectContaining({ role: 'year', resourceKey: 'period/2' }),
|
||||
]);
|
||||
});
|
||||
|
||||
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: 'missing',
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
}),
|
||||
]);
|
||||
expect(response.data?.yearComparison).toBeNull();
|
||||
expect(
|
||||
response.meta.dependencies.some((dependency) => dependency.role !== 'selected'),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
function seed(items: BrokerageOperationDto[]) {
|
||||
@@ -63,7 +41,7 @@ describe('IncomeAnalyticsService', () => {
|
||||
build: (opened?: Date) => unknown[],
|
||||
) => {
|
||||
build(undefined);
|
||||
return [items, [], []].map((items, index) => ({
|
||||
return [items].map((items, index) => ({
|
||||
items,
|
||||
meta: completeMeta(index),
|
||||
}));
|
||||
@@ -247,8 +225,6 @@ describe('IncomeAnalyticsService', () => {
|
||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
||||
],
|
||||
[],
|
||||
[],
|
||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||
},
|
||||
);
|
||||
|
||||
@@ -6,9 +6,9 @@ import {
|
||||
compare,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
import {
|
||||
IncomeAnalyticsEntryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
IncomeComparisonDto,
|
||||
IncomeCurrencySummaryDto,
|
||||
IncomeDependencyDto,
|
||||
IncomeInstrumentDto,
|
||||
@@ -45,17 +44,12 @@ export class IncomeAnalyticsService {
|
||||
async summary(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
observe = false,
|
||||
): Promise<IncomeSummaryResourceDto> {
|
||||
let plan = planIncomePeriod(undefined, undefined);
|
||||
const plan = planIncomePeriod(undefined, undefined);
|
||||
const [selected] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(undefined, undefined, openedAt);
|
||||
return [plan.selected];
|
||||
},
|
||||
observe,
|
||||
() => [plan.selected],
|
||||
);
|
||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||
assert(selected.meta.lastSuccessfulAt);
|
||||
@@ -75,56 +69,33 @@ export class IncomeAnalyticsService {
|
||||
accountId: string,
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
observe = false,
|
||||
): Promise<IncomeAnalyticsResourceDto> {
|
||||
let plan = planIncomePeriod(from, to);
|
||||
const periods = await this.operations.collectRanges(
|
||||
const plan = planIncomePeriod(from, to);
|
||||
const [current] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
(openedAt) => {
|
||||
plan = planIncomePeriod(from, to, openedAt);
|
||||
return [
|
||||
plan.selected,
|
||||
plan.previous,
|
||||
...(plan.yearAgo ? [plan.yearAgo] : []),
|
||||
];
|
||||
},
|
||||
observe,
|
||||
() => [plan.selected],
|
||||
);
|
||||
const [current, prior, year] = periods;
|
||||
// Preserve the legacy order while consumers migrate to explicit roles.
|
||||
const dependencies: IncomeDependencyDto[] = [
|
||||
{ ...current.meta, role: 'selected' },
|
||||
{ ...prior.meta, role: 'previous' },
|
||||
{
|
||||
...(plan.yearAgo && year
|
||||
? year.meta
|
||||
: investmentResourceMeta({
|
||||
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
|
||||
dataRevision: 'unavailable',
|
||||
availability: 'missing',
|
||||
fetchedAt: null,
|
||||
freshForMs: 0,
|
||||
work: null,
|
||||
unavailableReason: 'CALENDAR_OR_ACCOUNT_HISTORY',
|
||||
})),
|
||||
role: 'year',
|
||||
},
|
||||
];
|
||||
const states = periods.map((period) => period.meta.sync);
|
||||
const work =
|
||||
states.find((state) => state.state === 'running') ??
|
||||
states.find((state) => state.state === 'failed');
|
||||
const work = current.meta.sync;
|
||||
const meta = {
|
||||
...investmentResourceMeta({
|
||||
resourceKey: `income/${accountId}/${plan.selected.from}/${plan.selected.to}`,
|
||||
dataRevision: current.meta.dataRevision,
|
||||
availability: current.meta.availability,
|
||||
availability:
|
||||
current.items === null
|
||||
? 'temporarily-unavailable'
|
||||
: 'available',
|
||||
fetchedAt: current.meta.lastSuccessfulAt
|
||||
? new Date(current.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: current.meta.freshness === 'fresh' ? Infinity : STALE_FRESHNESS_MS,
|
||||
work: work?.id ? { ...work, id: work.id } : null,
|
||||
work: work.id
|
||||
? { id: work.id, state: work.state, errorCode: work.errorCode }
|
||||
: null,
|
||||
lastAttempt: current.meta.lastAttempt,
|
||||
coverage: current.meta.coverage,
|
||||
dependencies,
|
||||
}),
|
||||
@@ -144,14 +115,6 @@ export class IncomeAnalyticsService {
|
||||
selected,
|
||||
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,
|
||||
buckets: buckets(plan.selected, plan.granularity, selected),
|
||||
instruments: instruments(selected),
|
||||
@@ -195,38 +158,6 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
||||
.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(
|
||||
range: DateOnlyRange,
|
||||
granularity: IncomeBucketGranularity,
|
||||
@@ -290,22 +221,6 @@ function instrumentKey(entry: BrokerageOperationDto): string {
|
||||
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 {
|
||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
||||
}
|
||||
|
||||
@@ -23,16 +23,14 @@ describe('income period planning', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('uses a full previous month, skips impossible year-ago dates, and keeps empty buckets', () => {
|
||||
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();
|
||||
it('keeps empty buckets and skips removed comparison ranges', () => {
|
||||
expect(
|
||||
rangeBuckets({ from: '2026-01-01', to: '2026-02-15' }, 'week'),
|
||||
).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', () => {
|
||||
@@ -43,7 +41,6 @@ describe('income period planning', () => {
|
||||
expect(plan.selected).toEqual({ from: '2020-01-01', to: '2025-12-31' });
|
||||
expect(plan.granularity).toBe('month');
|
||||
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', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateParts,
|
||||
@@ -8,51 +9,34 @@ import {
|
||||
utcDate,
|
||||
} from '../investment-calendar';
|
||||
|
||||
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||
export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||
|
||||
const MONDAY_WEEKDAY_INDEX = 1;
|
||||
const INCLUSIVE_DAY_OFFSET = 1;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
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 WEEKLY_BUCKET_MAX_DAYS = 180;
|
||||
const DAYS_PER_WEEK = 7;
|
||||
const ISO_MONTH_LENGTH = 7;
|
||||
const ISO_YEAR_LENGTH = 4;
|
||||
|
||||
export type IncomeBucketGranularity = 'day' | 'month' | 'week';
|
||||
|
||||
export function planIncomePeriod(
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
openedDate?: Date,
|
||||
): {
|
||||
granularity: IncomeBucketGranularity;
|
||||
previous: DateOnlyRange;
|
||||
selected: DateOnlyRange;
|
||||
yearAgo: DateOnlyRange | null;
|
||||
} {
|
||||
if (Boolean(from) !== Boolean(to)) throw invalidPeriod();
|
||||
const today = moscowDate(new Date());
|
||||
const selected =
|
||||
from && to ? { from, to } : { from: firstOfMonth(today), to: 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 {
|
||||
selected,
|
||||
previous,
|
||||
yearAgo: yearAgoRange,
|
||||
granularity:
|
||||
rangeDays(selected) <= DAILY_BUCKET_MAX_DAYS
|
||||
? 'day'
|
||||
@@ -136,34 +120,6 @@ function endOfMonth(value: string): string {
|
||||
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
}
|
||||
|
||||
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 {
|
||||
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
|
||||
jest.mock('./catalog-enrichment.service', () => ({
|
||||
CatalogEnrichmentService: class {},
|
||||
}));
|
||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
TbankClient: class {},
|
||||
}));
|
||||
|
||||
@@ -5,15 +5,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
candleGaps,
|
||||
@@ -29,6 +30,7 @@ import { timestamp } from './instrument-market';
|
||||
|
||||
const REPLACED_WINDOW_COUNT = 1;
|
||||
const NEXT_WINDOW_OFFSET = 1;
|
||||
const NO_CANDLE_GAPS = 0;
|
||||
|
||||
type CandleInput = {
|
||||
instrumentType: 'bond' | 'share';
|
||||
@@ -97,7 +99,7 @@ export class CandleService {
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CandleInput, observe = false) {
|
||||
async read(userId: string, input: CandleInput) {
|
||||
const { revision } = await this.access.local(userId);
|
||||
const [detail] = await this.sources.readMany(
|
||||
userId,
|
||||
@@ -106,7 +108,7 @@ export class CandleService {
|
||||
true,
|
||||
['detail'],
|
||||
);
|
||||
if (detail.meta.availability === 'complete' && !detail.instrument) {
|
||||
if (detail.meta.completeness === 'complete' && !detail.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const now = new Date();
|
||||
@@ -114,8 +116,10 @@ export class CandleService {
|
||||
? rangeFor(input, detail.instrument, now)
|
||||
: null;
|
||||
const saved = range ? await this.repository.read(input.uid, range) : null;
|
||||
const gaps = range && saved ? candleGaps(range, saved.coverage, now) : [];
|
||||
if (!observe && (!saved || gaps.length)) {
|
||||
const hasCompleteRange = Boolean(
|
||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||
);
|
||||
if (!saved || !hasCompleteRange) {
|
||||
await this.request(userId, revision, input, now);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
|
||||
@@ -3,194 +3,62 @@ import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} 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 {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
type CatalogQuery,
|
||||
catalogScope,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import {
|
||||
catalogSearchLimit,
|
||||
selectCatalogInstruments,
|
||||
} from './catalog-selection';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||
const NO_MATCHES = 0;
|
||||
const FIRST_RESULT_OFFSET = 0;
|
||||
|
||||
const tickerAmbiguityLimit = 2;
|
||||
|
||||
type Producer = {
|
||||
attempt: TbankSyncAttempt;
|
||||
force: boolean;
|
||||
input: CatalogQuery;
|
||||
observers: TbankSyncAttempt[];
|
||||
};
|
||||
@Injectable()
|
||||
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(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly enrichment: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CatalogQuery, observe = false) {
|
||||
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: tbankSourceTtlMs.catalog,
|
||||
work: await this.repository.work(userId, connection.revision, input),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
||||
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.availability === 'complete')
|
||||
? 'complete'
|
||||
: 'partial',
|
||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
||||
? new Date(discovery.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: tbankSourceTtlMs.catalog,
|
||||
work: discovery.meta.sync.id
|
||||
? {
|
||||
id: discovery.meta.sync.id,
|
||||
state: discovery.meta.sync.state,
|
||||
errorCode: discovery.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
}),
|
||||
};
|
||||
async search(userId: string, input: CatalogQuery) {
|
||||
return this.onlineSearch(userId, catalogQuery(input));
|
||||
}
|
||||
|
||||
async detail(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
observe = false,
|
||||
) {
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
['detail'],
|
||||
const instrument = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.getShare(token, uid)
|
||||
: this.tbank.getBond(token, uid),
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.availability === '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);
|
||||
}
|
||||
if (!instrument) throw new NotFoundException();
|
||||
return {
|
||||
data: instrument ? { ...instrument, uid, slug } : null,
|
||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||
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.metadata,
|
||||
work: source.meta.sync.id
|
||||
? {
|
||||
id: source.meta.sync.id,
|
||||
state: source.meta.sync.state,
|
||||
errorCode: source.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -199,15 +67,20 @@ export class CatalogDiscoveryService {
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
ticker: string,
|
||||
observe = false,
|
||||
) {
|
||||
const lookup = await this.read(
|
||||
userId,
|
||||
{ instrumentType, query: ticker, exactTicker: true },
|
||||
observe,
|
||||
const query = ticker.trim().toUpperCase();
|
||||
const found = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.findShares(token, query)
|
||||
: this.tbank.findBonds(token, query),
|
||||
);
|
||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
const matches = selectCatalogInstruments(
|
||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||
query,
|
||||
instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||
if (!matches.length) throw new NotFoundException();
|
||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
@@ -215,192 +88,56 @@ export class CatalogDiscoveryService {
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = lookup.data
|
||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||
: null;
|
||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||
}
|
||||
|
||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
||||
const found =
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.findShares(token, input.query)
|
||||
: await this.tbank.findBonds(token, input.query);
|
||||
const instruments = selectCatalogInstruments(
|
||||
input.exactTicker
|
||||
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
|
||||
: found,
|
||||
input.query,
|
||||
input.instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||
const [details, prices] = await Promise.all([
|
||||
Promise.all(
|
||||
instruments.map((item) =>
|
||||
input.instrumentType === 'share'
|
||||
? this.tbank.getShare(token, item.uid)
|
||||
: this.tbank.getBond(token, item.uid),
|
||||
),
|
||||
),
|
||||
instruments.length
|
||||
? this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
|
||||
: [],
|
||||
]);
|
||||
return { details, instruments, prices };
|
||||
});
|
||||
const fetchedAt = new Date();
|
||||
const data = instruments.flatMap((item, index) => {
|
||||
const detail = details.at(index);
|
||||
return [{
|
||||
...item,
|
||||
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||
}];
|
||||
});
|
||||
return {
|
||||
data: result?.data
|
||||
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
||||
: null,
|
||||
data,
|
||||
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.metadata,
|
||||
work: lookup.meta.sync.id
|
||||
? {
|
||||
id: lookup.meta.sync.id,
|
||||
state: lookup.meta.sync.state,
|
||||
errorCode: lookup.meta.sync.errorCode,
|
||||
}
|
||||
: null,
|
||||
dependencies,
|
||||
resourceKey: `search:${catalogScope(input)}`,
|
||||
dataRevision: `online:${fetchedAt.toISOString()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt,
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
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 (
|
||||
!tbankSourceNeedsRefresh('catalog', saved.fetchedAt, new Date(), force)
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,43 +1,27 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
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 { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
TbankClient,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
TbankLastPrice,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogScope,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader';
|
||||
export type { CatalogPayload } from './catalog-source.loader';
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
const FIRST_QUEUE_INDEX = 0;
|
||||
@@ -58,7 +42,7 @@ export class CatalogEnrichmentService {
|
||||
private running = false;
|
||||
private readonly completions = new Map<
|
||||
string,
|
||||
{ done: Promise<void>; resolve: () => void; workId: string }
|
||||
{ attempt: TbankSyncAttempt; done: Promise<void>; resolve: () => void; workId: string }
|
||||
>();
|
||||
|
||||
async ensureDetail(
|
||||
@@ -92,9 +76,7 @@ export class CatalogEnrichmentService {
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly ratings: NcrRatingsClient,
|
||||
private readonly candles: CandleRepository,
|
||||
private readonly loader: CatalogSourceLoader,
|
||||
) {}
|
||||
|
||||
async readMany(
|
||||
@@ -109,6 +91,7 @@ export class CatalogEnrichmentService {
|
||||
tickers?: Record<string, string>;
|
||||
to: string;
|
||||
},
|
||||
refreshBefore?: Date,
|
||||
) {
|
||||
const { revision } = await this.access.local(userId);
|
||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||
@@ -122,6 +105,7 @@ export class CatalogEnrichmentService {
|
||||
false,
|
||||
kinds,
|
||||
range,
|
||||
refreshBefore,
|
||||
);
|
||||
}
|
||||
const work = inputs.length
|
||||
@@ -154,7 +138,7 @@ export class CatalogEnrichmentService {
|
||||
dataRevision: String(row?.dataRevision ?? MISSING_DATA_REVISION),
|
||||
availability: row ? 'complete' : 'missing',
|
||||
fetchedAt: row?.fetchedAt ?? null,
|
||||
freshForMs: tbankSourceTtlMs[sourcePolicy(input.kind)],
|
||||
freshForMs: sourcePolicy(input.kind) === 'quote' ? tbankSourceTtlMs.quote : Number.MAX_SAFE_INTEGER,
|
||||
work: byScope.get(catalogScope(input)) ?? null,
|
||||
}),
|
||||
};
|
||||
@@ -174,6 +158,7 @@ export class CatalogEnrichmentService {
|
||||
tickers?: Record<string, string>;
|
||||
to: string;
|
||||
},
|
||||
refreshBefore?: Date,
|
||||
) {
|
||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||
const reservation = this.reservations.then(async () => {
|
||||
@@ -186,14 +171,12 @@ export class CatalogEnrichmentService {
|
||||
(entry) =>
|
||||
entry.instrumentUid === input.uid && entry.kind === input.kind,
|
||||
);
|
||||
if (
|
||||
!tbankSourceNeedsRefresh(
|
||||
sourcePolicy(input.kind),
|
||||
row?.fetchedAt ?? null,
|
||||
new Date(),
|
||||
force,
|
||||
)
|
||||
) {
|
||||
const policy = sourcePolicy(input.kind);
|
||||
const needsRefresh = force || !row?.fetchedAt ||
|
||||
(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) {
|
||||
continue;
|
||||
}
|
||||
const reserved = await this.repository.reserve(
|
||||
@@ -212,7 +195,7 @@ export class CatalogEnrichmentService {
|
||||
const done = new Promise<void>((finish) => {
|
||||
resolve = finish;
|
||||
});
|
||||
this.completions.set(attemptId, { workId: id, done, resolve });
|
||||
this.completions.set(attemptId, { attempt, done, resolve, workId: id });
|
||||
const key = producerKey(input);
|
||||
const producer = this.producers.get(key);
|
||||
if (producer) producer.observers.push({ attempt, input });
|
||||
@@ -239,31 +222,57 @@ export class CatalogEnrichmentService {
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
section: 'company' | 'metrics' | 'quote',
|
||||
observe = false,
|
||||
) {
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
: [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
];
|
||||
if (section === 'company') {
|
||||
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
||||
const instrument =
|
||||
instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, uid)
|
||||
: await this.tbank.getBond(token, uid);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
const [description, fundamentals] = instrument.assetUid
|
||||
? await Promise.all([
|
||||
this.tbank.getAssetDescription(token, instrument.assetUid),
|
||||
this.tbank.getFundamentals(token, instrument.assetUid),
|
||||
])
|
||||
: [null, null];
|
||||
return { description, fundamentals, instrument };
|
||||
});
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/company/${uid}`,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
||||
work: null,
|
||||
});
|
||||
return {
|
||||
values: { instrument: publicInstrument({ ...instrument, uid }), description, fundamentals },
|
||||
meta,
|
||||
unavailable: [],
|
||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const kinds: CatalogSource['kind'][] = [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
];
|
||||
const sources = await this.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
if (first.meta.availability === 'complete' && !first.instrument) {
|
||||
if (first.meta.completeness === 'complete' && !first.instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const values = sources.reduce<CatalogPayload>(
|
||||
@@ -272,7 +281,7 @@ export class CatalogEnrichmentService {
|
||||
);
|
||||
const dependencies = sources.map((source) => source.meta);
|
||||
const complete = dependencies.every(
|
||||
(dependency) => dependency.availability === 'complete',
|
||||
(dependency) => dependency.completeness === 'complete',
|
||||
);
|
||||
const meta = investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/${section}/${uid}`,
|
||||
@@ -287,10 +296,7 @@ export class CatalogEnrichmentService {
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs:
|
||||
section === 'company'
|
||||
? tbankSourceTtlMs.company
|
||||
: tbankSourceTtlMs.quote,
|
||||
freshForMs: tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
@@ -300,7 +306,7 @@ export class CatalogEnrichmentService {
|
||||
unavailable: sources
|
||||
.filter(
|
||||
(source) =>
|
||||
source.meta.availability !== 'complete' ||
|
||||
source.meta.completeness !== 'complete' ||
|
||||
source.meta.sync.state === 'failed',
|
||||
)
|
||||
.map((source) => source.input.kind),
|
||||
@@ -308,168 +314,7 @@ export class CatalogEnrichmentService {
|
||||
};
|
||||
}
|
||||
|
||||
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) : 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.
|
||||
private async drain() {
|
||||
try {
|
||||
while (this.queue.length) {
|
||||
@@ -506,7 +351,7 @@ export class CatalogEnrichmentService {
|
||||
batch.map((item) => item.input.uid),
|
||||
);
|
||||
} else {
|
||||
payload = await this.load(producer.input, token, producer.attempt);
|
||||
payload = await this.loader.load(producer.input, token, producer.attempt);
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
@@ -556,6 +401,14 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
}
|
||||
} 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();
|
||||
this.completions.clear();
|
||||
this.active.clear();
|
||||
@@ -600,57 +453,7 @@ function sourceInputs(
|
||||
})),
|
||||
);
|
||||
}
|
||||
function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
return {
|
||||
uid: value.uid,
|
||||
figi: value.figi,
|
||||
assetUid: value.assetUid,
|
||||
name: value.name,
|
||||
ticker: value.ticker,
|
||||
isin: value.isin,
|
||||
classCode: value.classCode,
|
||||
realExchange: value.realExchange,
|
||||
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
|
||||
currency: value.currency,
|
||||
shareType: (value as TbankShare).shareType,
|
||||
sector: value.sector,
|
||||
lot: value.lot,
|
||||
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
|
||||
first_1dayCandleDate: value.first_1dayCandleDate,
|
||||
nominal: bond.nominal,
|
||||
initialNominal: bond.initialNominal,
|
||||
aciValue: bond.aciValue,
|
||||
maturityDate: bond.maturityDate,
|
||||
callDate: bond.callDate,
|
||||
couponQuantityPerYear: bond.couponQuantityPerYear,
|
||||
floatingCouponFlag: bond.floatingCouponFlag,
|
||||
perpetualFlag: bond.perpetualFlag,
|
||||
amortizationFlag: bond.amortizationFlag,
|
||||
subordinatedFlag: bond.subordinatedFlag,
|
||||
bondType: bond.bondType,
|
||||
riskLevel: bond.riskLevel,
|
||||
};
|
||||
}
|
||||
|
||||
const previousCloseLookbackDays = 20;
|
||||
const 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 sourcePolicy(kind: CatalogSource['kind']) {
|
||||
if (
|
||||
[
|
||||
@@ -663,9 +466,8 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
||||
) {
|
||||
return 'quote';
|
||||
}
|
||||
if (kind === 'description' || kind === 'fundamentals') return 'company';
|
||||
if (['bond-events', 'coupons', 'dividends'].includes(kind)) return 'calendar';
|
||||
return 'metadata';
|
||||
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
|
||||
return 'stable';
|
||||
}
|
||||
function producerKey(input: CatalogSource) {
|
||||
return input.instrumentType === 'bond' &&
|
||||
@@ -673,6 +475,3 @@ function producerKey(input: CatalogSource) {
|
||||
? `bond-metadata:${input.uid}`
|
||||
: catalogScope(input);
|
||||
}
|
||||
|
||||
const quotationScale = 9;
|
||||
const quotationUnits = 1_000_000_000n;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
export const catalogSearchLimit = 30;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
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 {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
|
||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ const row = (
|
||||
isin: null,
|
||||
ticker: null,
|
||||
logoName: null,
|
||||
sector: null,
|
||||
issuer: null,
|
||||
});
|
||||
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||
positions,
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../common/decimal';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
} from '#src/common/decimal';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
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,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/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 { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const MIN_LOT_EXCLUSIVE = 0;
|
||||
@@ -25,12 +27,10 @@ export class BondCatalogService {
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<BondSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'bond', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
@@ -58,9 +58,8 @@ export class BondCatalogService {
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
const result = await this.discovery.detail(userId, 'bond', uid);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
@@ -72,13 +71,11 @@ export class BondCatalogService {
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'bond',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -5,19 +5,20 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
@@ -54,14 +55,12 @@ export class BondIncomeService {
|
||||
async metrics(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondMetricsResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'metrics',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -71,8 +70,8 @@ export class BondIncomeService {
|
||||
};
|
||||
}
|
||||
|
||||
async readPosition(userId: string, uid: string, observe = false) {
|
||||
const saved = await this.portfolios.read(userId, 'all', observe);
|
||||
async readPosition(userId: string, uid: string) {
|
||||
const saved = await this.portfolios.read(userId, 'all');
|
||||
return {
|
||||
data: saved.data ? instrumentPosition(saved.data, uid, 'BOND') : null,
|
||||
meta: { ...saved.meta, resourceKey: `bond/position/${uid}` },
|
||||
@@ -82,22 +81,21 @@ export class BondIncomeService {
|
||||
async coupons(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondCouponsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
const from = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) - INCOME_HISTORY_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const to = `${Number(today.slice(ISO_DATE_START, ISO_YEAR_LENGTH)) + INCOME_FORECAST_YEARS}${today.slice(ISO_YEAR_LENGTH)}`;
|
||||
const portfolio = await this.portfolios.read(userId, 'all', observe);
|
||||
const portfolio = await this.portfolios.read(userId, 'all');
|
||||
const sources = await this.sources.readMany(
|
||||
userId,
|
||||
'bond',
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
if (sources[0].meta.availability === 'complete' && !sources[0].instrument) {
|
||||
if (sources[0].meta.completeness === 'complete' && !sources[0].instrument) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const events = sources[1].payload?.coupons;
|
||||
@@ -137,7 +135,7 @@ export class BondIncomeService {
|
||||
projected.observedAt,
|
||||
true,
|
||||
),
|
||||
meta: { ...result.meta, availability: 'partial' },
|
||||
meta: { ...result.meta, completeness: 'incomplete' },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
add,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -38,14 +39,12 @@ export class BondMarketService {
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -59,12 +58,10 @@ export class BondMarketService {
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: BondCandlesQueryDto['period'] = 'month',
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
{ uid, instrumentType: 'bond', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export function money(value?: TbankMoney) {
|
||||
const currency = value?.currency?.toUpperCase();
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
import { BondIncomeService } from '../application/bond-income.service';
|
||||
import { BondMarketService } from '../application/bond-market.service';
|
||||
@@ -47,9 +47,8 @@ export class BondController {
|
||||
metrics(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.metrics(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -61,7 +60,6 @@ export class BondController {
|
||||
return this.bonds.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +68,10 @@ export class BondController {
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: BondTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,9 +80,8 @@ export class BondController {
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.bonds.get(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@@ -94,9 +89,8 @@ export class BondController {
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@@ -110,7 +104,6 @@ export class BondController {
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -119,13 +112,8 @@ export class BondController {
|
||||
position(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.readPosition(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
return this.income.readPosition(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/coupons')
|
||||
@@ -133,8 +121,7 @@ export class BondController {
|
||||
coupons(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.coupons(request.user.sub, uid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
|
||||
|
||||
import {
|
||||
StockCandlesDto,
|
||||
StockCandlesQueryDto,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
const FIRST_RANGE_END_INDEX = 1;
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
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 {
|
||||
type CandleRange,
|
||||
confirmedCandleCoverage,
|
||||
@@ -62,34 +71,30 @@ export class CandleRepository {
|
||||
priority = 1,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { ...owner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, owner);
|
||||
if (!current) return null;
|
||||
const scope = { ...owner, scopeKey };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
) {
|
||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
||||
const joinable = isJoinableRunningAttempt(previous, runId, active)
|
||||
? previous
|
||||
: null;
|
||||
if (joinable) {
|
||||
const previousIntent = joinable.intent as Prisma.JsonObject;
|
||||
const previousNow =
|
||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||
const nextPriority = Math.min(previous.priority, priority);
|
||||
const nextPriority = Math.min(joinable.priority, priority);
|
||||
if (
|
||||
nextPriority === previous.priority &&
|
||||
nextPriority === joinable.priority &&
|
||||
(!intent.force ||
|
||||
(previousIntent.force === true &&
|
||||
(!intent.now || intent.now <= previousNow)))
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
return { work: joinable, launch: false };
|
||||
}
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
where: { id: joinable.id },
|
||||
data: {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
@@ -115,7 +120,7 @@ export class CandleRepository {
|
||||
processRunId: runId,
|
||||
state: 'running',
|
||||
priority,
|
||||
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
||||
intent: toJson(intent),
|
||||
progress: {},
|
||||
errorCode: null,
|
||||
};
|
||||
@@ -163,24 +168,17 @@ export class CandleRepository {
|
||||
).values(),
|
||||
];
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { progress: { lastWindow: key } },
|
||||
const valid = await claimSyncWork(tx, attempt, {
|
||||
progress: { lastWindow: key },
|
||||
});
|
||||
if (!valid.count) return false;
|
||||
if (!valid) return false;
|
||||
const identity = {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
sourceKey: 'candles',
|
||||
itemKey: key,
|
||||
};
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({ range, candles: rows, fetchedAt }),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson({ range, candles: rows, fetchedAt });
|
||||
await tx.tbankSyncStage.upsert({
|
||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||
create: { ...identity, payload },
|
||||
@@ -196,18 +194,11 @@ export class CandleRepository {
|
||||
completeWork = true,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: {
|
||||
...(completeWork ? { state: 'complete' } : {}),
|
||||
errorCode: null,
|
||||
},
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
...(completeWork ? { state: 'complete' } : {}),
|
||||
errorCode: null,
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
const staged = await tx.tbankSyncStage.findMany({
|
||||
where: {
|
||||
workId: attempt.id,
|
||||
@@ -311,28 +302,17 @@ export class CandleRepository {
|
||||
},
|
||||
});
|
||||
if (!origin) return false;
|
||||
const result = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', errorCode: null },
|
||||
const result = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
errorCode: null,
|
||||
});
|
||||
return result.count > NO_UPDATED_ROWS;
|
||||
return result > NO_UPDATED_ROWS;
|
||||
});
|
||||
}
|
||||
|
||||
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode },
|
||||
});
|
||||
await claimSyncWork(tx, attempt, { state: 'failed', errorCode });
|
||||
await tx.tbankSyncStage.deleteMany({
|
||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||
});
|
||||
|
||||
+46
-75
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
const REVISION_INCREMENT = 1;
|
||||
@@ -159,27 +159,19 @@ export class CatalogDiscoveryRepository {
|
||||
initiator: TbankSyncAttempt = attempt,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const origin = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...initiator,
|
||||
state: { in: ['running', 'complete'] },
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { progress: {} },
|
||||
const origin = await claimSyncWork(
|
||||
tx,
|
||||
initiator,
|
||||
{ progress: {} },
|
||||
{ state: { in: ['running', 'complete'] } },
|
||||
);
|
||||
if (!origin) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!origin.count) return false;
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(value),
|
||||
) as Prisma.InputJsonObject;
|
||||
if (!claimed) return false;
|
||||
const payload = toJson(value);
|
||||
const identity = { provider, uid: input.uid };
|
||||
await tx.tbankInstrument.upsert({
|
||||
where: { provider_uid: identity },
|
||||
@@ -202,12 +194,10 @@ export class CatalogDiscoveryRepository {
|
||||
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||
where: { provider_uid: identity },
|
||||
});
|
||||
const metadata = JSON.parse(
|
||||
JSON.stringify({
|
||||
...instrument,
|
||||
instrumentType: input.instrumentType,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
const metadata = toJson({
|
||||
...instrument,
|
||||
instrumentType: input.instrumentType,
|
||||
});
|
||||
await tx.tbankInstrument.update({
|
||||
where: { provider_uid: identity },
|
||||
data: {
|
||||
@@ -284,20 +274,13 @@ export class CatalogDiscoveryRepository {
|
||||
active: ReadonlySet<string>,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { ...owner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, owner);
|
||||
if (!current) return null;
|
||||
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
) {
|
||||
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
@@ -345,34 +328,28 @@ export class CatalogDiscoveryRepository {
|
||||
now: Date,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
for (const instrument of instruments) {
|
||||
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||
const identity = { provider, uid: instrument.uid };
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({
|
||||
uid: instrument.uid,
|
||||
figi: instrument.figi,
|
||||
ticker: instrument.ticker,
|
||||
name: instrument.name,
|
||||
isin: instrument.isin,
|
||||
classCode: instrument.classCode,
|
||||
instrumentKind: instrument.instrumentKind,
|
||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||
currency: instrument.currency,
|
||||
brand: instrument.brand
|
||||
? { logoName: instrument.brand.logoName }
|
||||
: undefined,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson({
|
||||
uid: instrument.uid,
|
||||
figi: instrument.figi,
|
||||
ticker: instrument.ticker,
|
||||
name: instrument.name,
|
||||
isin: instrument.isin,
|
||||
classCode: instrument.classCode,
|
||||
instrumentKind: instrument.instrumentKind,
|
||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||
currency: instrument.currency,
|
||||
brand: instrument.brand
|
||||
? { logoName: instrument.brand.logoName }
|
||||
: undefined,
|
||||
});
|
||||
await tx.tbankInstrument.upsert({
|
||||
where: { provider_uid: identity },
|
||||
update: {},
|
||||
@@ -465,6 +442,7 @@ export class CatalogDiscoveryRepository {
|
||||
|
||||
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
@@ -473,16 +451,9 @@ export class CatalogDiscoveryRepository {
|
||||
},
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
await claimSyncWork(tx, attempt, {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import type { SavedCandle } from '../domain/candle-coverage';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { timestamp } from '../application/instrument-market';
|
||||
|
||||
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { MoexModule } from '#src/integrations/moex/moex.module';
|
||||
import { NcrModule } from '#src/integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { CandleService } from './application/candle.service';
|
||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
import { CatalogSourceLoader } from './application/catalog-source.loader';
|
||||
import { CandleRepository } from './infrastructure/candle.repository';
|
||||
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||
|
||||
@@ -15,6 +17,7 @@ import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.r
|
||||
CandleService,
|
||||
CandleRepository,
|
||||
CatalogEnrichmentService,
|
||||
CatalogSourceLoader,
|
||||
CatalogDiscoveryService,
|
||||
CatalogDiscoveryRepository,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||
@@ -12,6 +13,7 @@ import { GetStockCompanyUseCase } from './stocks/application/get-stock-company.u
|
||||
import { StockCatalogService } from './stocks/application/stock-catalog.service';
|
||||
import { StockIncomeService } from './stocks/application/stock-income.service';
|
||||
import { StockMarketService } from './stocks/application/stock-market.service';
|
||||
import { StockScreenerService } from './stocks/application/stock-screener.service';
|
||||
import { StockController } from './stocks/presentation/stock.controller';
|
||||
|
||||
@Module({
|
||||
@@ -25,6 +27,7 @@ import { StockController } from './stocks/presentation/stock.controller';
|
||||
StockIncomeService,
|
||||
StockMarketService,
|
||||
StockCatalogService,
|
||||
StockScreenerService,
|
||||
BondCatalogService,
|
||||
],
|
||||
})
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import {
|
||||
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
|
||||
async execute(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockCompanyResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'share',
|
||||
uid,
|
||||
'company',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user