57 Commits
Author SHA1 Message Date
ksv741 a7e98c0ec3 refactor: enforce repository boundary in application (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m41s
Backend verify / security (pull_request) Successful in 1m13s
Backend verify / verify (pull_request) Successful in 6m50s
2026-11-04 16:53:41 +03:00
ksv741 4e3e58e288 refactor: move auth persistence into repository (WealthLedger/backend#171) 2026-11-04 16:46:49 +03:00
ksv741 1ddebbdbad refactor: route bank operation queries through repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m39s
Backend verify / security (pull_request) Successful in 1m12s
Backend verify / verify (pull_request) Successful in 6m57s
2026-11-04 16:46:21 +03:00
ksv741 6488e5d1ad refactor: move bank card queries into repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m35s
Backend verify / security (pull_request) Successful in 1m10s
Backend verify / verify (pull_request) Successful in 6m39s
2026-11-04 16:34:40 +03:00
ksv741 5c7ff312ee refactor: route bank card reads through repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m30s
Backend verify / security (pull_request) Successful in 1m8s
Backend verify / verify (pull_request) Failing after 5m52s
2026-11-04 16:30:45 +03:00
ksv741 0f65ba7f13 refactor: move category persistence into bank repository (WealthLedger/backend#171)
Backend verify / commits (pull_request) Successful in 1m30s
Backend verify / security (pull_request) Successful in 1m14s
Backend verify / verify (pull_request) Successful in 6m49s
2026-11-04 14:52:38 +03:00
codex-bot fa0a6e5603 refactor: share sync reservation join rule (WealthLedger/backend#119) (#162)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m10s
Backend verify / verify (push) Failing after 7m39s
2026-11-03 22:53:54 +03:00
codex-bot bade5fae1c chore(deps): set Renovate release age, PR limit and update groups (WealthLedger/main#6) (#160)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m2s
Backend verify / verify (push) Failing after 6m42s
2026-11-03 14:07:40 +03:00
codex-bot 1a5de9bebd ci: drop stale npm audit advisory ignore (WealthLedger/backend#158) (#159)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 6m59s
2026-11-03 12:04:10 +03:00
codex-bot aeb4647fda fix(events): refresh saved payout calendars once per hourly pass (WealthLedger/backend#53) (#157)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m50s
2026-11-02 23:06:14 +03:00
codex-bot 169bee1da5 fix(portfolio): publish new complete forecast regardless of request window (WealthLedger/backend#55) (#156)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m11s
Backend verify / verify (push) Failing after 7m6s
2026-11-02 22:42:46 +03:00
codex-bot 554410a271 refactor: inject TbankAccessService directly into stock screener (#151) (#155)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m0s
Backend verify / verify (push) Failing after 6m12s
2026-11-02 07:53:11 +03:00
codex-bot a80bbbf1e2 refactor: check persisted json payload shape instead of double casts (#152) (#154)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 59s
Backend verify / verify (push) Failing after 6m3s
2026-11-02 07:31:24 +03:00
ksv741 a18d9dbdc8 chore(release): 2.4.0
Backend verify / commits (pull_request) Successful in 1m28s
Backend verify / security (pull_request) Successful in 1m3s
Backend verify / verify (pull_request) Successful in 5m52s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Successful in 5m30s
Backend image / publish (push) Successful in 8m40s
2026-11-01 19:33:18 +03:00
codex-bot 17432dfee3 feat(portfolio): store and publish instrument sector and issuer (WealthLedger/main#48) (#148)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m4s
Backend verify / verify (push) Failing after 7m15s
2026-11-01 19:13:32 +03:00
codex-bot 5e84e1e5f1 fix: scope income and ledger sync to requested intervals (WealthLedger/backend#57) (#147)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 5m52s
2026-10-28 21:29:53 +03:00
codex-bot e350146749 test: cover portfolio publish rollback and claim fence (WealthLedger/backend#105) (#146)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 6m11s
2026-10-28 21:10:50 +03:00
codex-bot 99b137ed0b test: replace operations sync delays with explicit barriers (WealthLedger/backend#104) (#145)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 6m12s
2026-10-28 20:41:24 +03:00
codex-bot d37fa2c903 refactor: publish typed problem detail codes (WealthLedger/backend#117) (#144)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m8s
2026-10-28 17:11:41 +03:00
codex-bot 5295945ab2 refactor: expose tbank-sync public api and guard its boundary (WealthLedger/backend#118) (#143)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m28s
2026-10-28 16:40:01 +03:00
codex-bot 22668da3fa refactor: remove tbank-sync operations import cycle (WealthLedger/backend#116) (#142)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m14s
2026-10-28 16:26:49 +03:00
ksv741 2325b63883 fix: reset portfolio value points inflated by double-counted cash (WealthLedger/backend#140)
Backend verify / commits (pull_request) Successful in 27s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m33s
2026-10-28 08:00:20 +03:00
ksv741 f2843e30bd fix: count brokerage cash once and skip short-position return percent (WealthLedger/backend#140) 2026-10-28 08:00:20 +03:00
codex-bot 97fc41fa29 feat: extend account list portfolio summary (#138)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 8s
Backend verify / verify (push) Failing after 4m43s
2026-10-27 05:44:07 +03:00
ksv741 3dcd1a69a6 chore: restore main ancestry in develop (WealthLedger/backend#135)
Backend verify / commits (pull_request) Successful in 31s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m47s
2026-10-25 10:06:30 +03:00
codex-botandksv741 8503429b46 chore(release): 2.3.1 (#132)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 23s
Backend verify / verify (push) Successful in 6m19s
Backend image / publish (push) Successful in 13m17s
Backend patch #129 for WealthLedger/deploy#12. Verified release SHA `0dff4b43773041a73a725fa0b9d0c665eac0cab2`; fast-forward-only main then tag on exact SHA.

---------

Co-authored-by: Sergey Krylov <ksv741@mail.ru>
Reviewed-on: #132
Co-authored-by: codex-bot <codex-bot@ksv741.ru>
2026-10-24 21:32:57 +03:00
ksv741 0dff4b4377 chore(release): 2.3.1
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / commits (pull_request) Successful in 33s
Backend verify / verify (push) Failing after 6m28s
Backend verify / security (pull_request) Successful in 10s
Backend verify / verify (pull_request) Successful in 6m13s
2026-10-24 21:00:38 +03:00
ksv741 a250142a86 fix: use available Debian packages in image build (#129)
Backend verify / commits (pull_request) Successful in 38s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 6m18s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m38s
2026-10-24 20:48:55 +03:00
ksv741 0a0e70b273 chore(release): 2.3.0
Backend verify / commits (pull_request) Successful in 35s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 6m23s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend image / publish (push) Failing after 6m34s
Backend verify / verify (push) Successful in 6m36s
2026-10-24 19:15:04 +03:00
ksv741 449cfcd6c5 chore(deps): update non-major dependencies #11
Backend verify / commits (pull_request) Successful in 30s
Backend verify / security (pull_request) Successful in 9s
Backend verify / verify (pull_request) Successful in 5m31s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m51s
2026-10-24 18:20:20 +03:00
codex-bot 966a1f793a feat: log unhandled errors and pretty dev output (WealthLedger/backend#120) (#126)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 5m39s
feat: log unhandled errors and pretty dev output (#120)
2026-10-24 15:06:34 +03:00
codex-bot d1f6969dc7 fix: persist refresh cookie across browser restarts (WealthLedger/backend#123)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 9s
Backend verify / verify (push) Failing after 4m58s
2026-10-23 08:34:39 +03:00
ksv741 b20080ac85 chore(release): 2.2.0
Backend verify / commits (pull_request) Successful in 33s
Backend verify / security (pull_request) Successful in 13s
Backend verify / verify (pull_request) Successful in 5m38s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend image / publish (push) Successful in 7m58s
Backend verify / verify (push) Successful in 5m40s
2026-10-18 16:25:22 +03:00
codex-bot 32622c1dad test: cover auth token, cookie and expiry boundaries (WealthLedger/backend#61) (#113)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m26s
2026-10-18 15:55:05 +03:00
codex-bot 4f485ab0e9 chore: adopt linear git model in backend docs (#111)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 30s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m44s
2026-10-17 14:19:19 +03:00
codex-bot 8a6f60d965 fix: translate permanent invalid-token errors on online entrypoints (WealthLedger/backend#59) (#110)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 33s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m40s
2026-10-16 23:23:12 +03:00
codex-bot e03fad622b fix: align password policy to 8-72 printable ASCII on register (WealthLedger/backend#60) (#109)
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 32s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 6m21s
2026-10-16 10:00:47 +03:00
ksv741 774e58ef80 Merge branch 'chore/30-sync-release' into develop
Backend verify / commits (push) Skipped
Backend verify / commits (pull_request) Failing after 28s
Backend verify / security (pull_request) Skipped
Backend verify / verify (pull_request) Skipped
Backend verify / security (push) Successful in 10s
Backend verify / verify (push) Failing after 5m38s
2026-10-16 07:38:58 +03:00
codex-bot 702302126b test: await portfolio publication done instead of polling ticks (WealthLedger/backend#63) (#103)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 5m25s
Backend verify / commits (pull_request) Successful in 29s
Backend verify / security (pull_request) Successful in 11s
Backend verify / verify (pull_request) Successful in 4m39s
2026-10-15 15:40:03 +03:00
codex-bot ec6d9a2639 test: replace metadata sleeps with upstream start barriers (WealthLedger/backend#63) (#102)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 15s
Backend verify / verify (push) Failing after 6m46s
2026-10-15 14:46:09 +03:00
codex-bot 7bc7d8ddec test: replace late portfolio sleep with started and done barriers (WealthLedger/backend#63) (#101)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 6m21s
2026-10-15 12:23:14 +03:00
codex-bot 9e4f9d9cab test: isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend#65) (#100)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m59s
2026-10-15 11:50:47 +03:00
codex-bot a93f2d55e7 chore: make OpenAPI export atomic and add read-only check (WealthLedger/backend#3) (#97)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m4s
2026-10-15 11:24:38 +03:00
codex-bot 96d4de4195 refactor: remove unreachable catalog producer and sync orchestration (WealthLedger/backend#62) (#98)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m47s
2026-10-15 10:46:58 +03:00
codex-bot c789e33c41 chore: drop unused direct source-map-support dependency (WealthLedger/backend#4) (#96)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m22s
2026-10-15 06:15:47 +03:00
codex-bot 2ad3517d87 fix: restore screener OpenAPI constraints (WealthLedger/backend#70) (#95)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 12s
Backend verify / verify (push) Failing after 5m50s
2026-10-15 06:00:39 +03:00
codex-bot 0d7839cf79 fix: separate T-Bank RPC network time from queue wait (WealthLedger/backend#81) (#94)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Successful in 5m20s
2026-10-15 05:41:21 +03:00
codex-bot 6975853656 fix: skip hourly terms overlapped by a long walk (WealthLedger/backend#54)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m56s
2026-10-14 22:43:08 +03:00
codex-bot 14bcb9da5f fix: surface durable hourly attempt in accounts and income meta (WealthLedger/backend#58)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 17s
Backend verify / verify (push) Failing after 7m31s
2026-10-14 18:00:20 +03:00
codex-bot b90cedd580 chore: unify verification preparation (WealthLedger/backend#66)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m44s
2026-10-14 17:01:53 +03:00
codex-bot da2e1d0781 perf: start bond search prices with details (WealthLedger/backend#71) (#89)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 14s
Backend verify / verify (push) Failing after 6m48s
2026-10-14 14:04:07 +03:00
codex-bot bbb5afff84 ci: validate edited PR titles (WealthLedger/backend#74)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 6m37s
Closes #74
2026-10-14 09:02:53 +03:00
codex-bot 285dd08472 perf: batch bank operation page relations (WealthLedger/backend#68)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 6m20s
Closes #68
2026-10-14 08:48:37 +03:00
codex-bot 1033d2114e test: slim sqlite runner regression workload (WealthLedger/backend#72)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 5m47s
Closes #72
2026-10-14 08:34:37 +03:00
codex-bot 203cc72cb2 chore: align SQLite driver version (WealthLedger/backend#67)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 11s
Backend verify / verify (push) Failing after 4m18s
Closes #67
2026-10-14 08:12:51 +03:00
codex-bot 9b7e5e1215 refactor: extract shared public instrument projection (#69) (#84)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 18s
Backend verify / verify (push) Failing after 6m47s
2026-10-13 21:54:51 +03:00
codex-bot 31b0e15579 feat: unify income payment cards and drop period comparisons (WealthLedger/main#33) (#83)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 13s
Backend verify / verify (push) Failing after 7m1s
feat: unify income payment cards and drop period comparisons (WealthLedger/main#33)
2026-10-13 18:16:46 +03:00
113 changed files with 4955 additions and 3639 deletions
+6 -6
View File
@@ -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
View File
@@ -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
+6 -1
View File
@@ -89,4 +89,9 @@ Commits и PR titles — Conventional Commits с parser-recognized Issue referen
`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, раздел «Релизы».
+1 -2
View File
@@ -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 }}
+6 -6
View File
@@ -4,7 +4,7 @@ on:
push:
branches: [develop, main]
pull_request:
types: [opened, synchronize, reopened]
types: [opened, synchronize, reopened, edited]
schedule:
- cron: '47 5 * * *'
@@ -44,6 +44,7 @@ jobs:
needs: commits
if: >-
!cancelled() &&
gitea.event.action != 'edited' &&
(needs.commits.result == 'success' || needs.commits.result == 'skipped')
timeout-minutes: 15
runs-on: ubuntu-latest
@@ -66,12 +67,13 @@ jobs:
- name: Audit runtime and development dependencies
run: |
npm audit --omit=dev --audit-level=low
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
npm audit --include=dev --audit-level=high
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
@@ -85,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.
+1 -1
View File
@@ -1 +1 @@
24.21.0
24.22.1
+91
View File
@@ -1,5 +1,96 @@
# Changelog
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
### Features
* extend account list portfolio summary ([#138](https://git.personal.ksv741.ru/WealthLedger/backend/issues/138)) ([97fc41f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97fc41fa295a7a943a27171fa41cdaf286632956))
* **portfolio:** store and publish instrument sector and issuer (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/backend/issues/48)) ([#148](https://git.personal.ksv741.ru/WealthLedger/backend/issues/148)) ([17432df](https://git.personal.ksv741.ru/WealthLedger/backend/commit/17432dfee3d7d06841caea4419a062e2a4bfc82c))
### Bug Fixes
* count brokerage cash once and skip short-position return percent (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([f2843e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f2843e30bda5442158ec145cc63a5491506bb60f))
* reset portfolio value points inflated by double-counted cash (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([2325b63](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd))
* scope income and ledger sync to requested intervals (WealthLedger/backend[#57](https://git.personal.ksv741.ru/WealthLedger/backend/issues/57)) ([#147](https://git.personal.ksv741.ru/WealthLedger/backend/issues/147)) ([5e84e1e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5e84e1e5f1943b5af1992a3a946194b27fc1a1d5))
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
### Refactoring
* expose tbank-sync public api and guard its boundary (WealthLedger/backend[#118](https://git.personal.ksv741.ru/WealthLedger/backend/issues/118)) ([#143](https://git.personal.ksv741.ru/WealthLedger/backend/issues/143)) ([5295945](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5295945ab237a6e94aa64d7bb54bfd04554ca9f9))
* publish typed problem detail codes (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/backend/issues/117)) ([#144](https://git.personal.ksv741.ru/WealthLedger/backend/issues/144)) ([d37fa2c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d37fa2c9034e90e9d22d4022c45fc36c22dd2ab3))
* remove tbank-sync operations import cycle (WealthLedger/backend[#116](https://git.personal.ksv741.ru/WealthLedger/backend/issues/116)) ([#142](https://git.personal.ksv741.ru/WealthLedger/backend/issues/142)) ([22668da](https://git.personal.ksv741.ru/WealthLedger/backend/commit/22668da3fa0666bdd22737913379c99d4cf01993))
### Tests
* cover portfolio publish rollback and claim fence (WealthLedger/backend[#105](https://git.personal.ksv741.ru/WealthLedger/backend/issues/105)) ([#146](https://git.personal.ksv741.ru/WealthLedger/backend/issues/146)) ([e350146](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e350146749293f2eaf8060f7626ff3c1cd8edf2c))
* replace operations sync delays with explicit barriers (WealthLedger/backend[#104](https://git.personal.ksv741.ru/WealthLedger/backend/issues/104)) ([#145](https://git.personal.ksv741.ru/WealthLedger/backend/issues/145)) ([99b137e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/99b137ed0b39ab73b6a28d1a494d2d98bf8fa380))
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
### Bug Fixes
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
## [2.3.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.2.0...v2.3.0) (2026-10-24)
### Features
* log unhandled errors and pretty dev output (WealthLedger/backend[#120](https://git.personal.ksv741.ru/WealthLedger/backend/issues/120)) ([#126](https://git.personal.ksv741.ru/WealthLedger/backend/issues/126)) ([966a1f7](https://git.personal.ksv741.ru/WealthLedger/backend/commit/966a1f793aa2087cce3e47750e0017f78315bd64))
### Bug Fixes
* persist refresh cookie across browser restarts (WealthLedger/backend[#123](https://git.personal.ksv741.ru/WealthLedger/backend/issues/123)) ([d1f6969](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d1f6969dc7c22ae3cf7396bccdbf0cea2f61db2e))
### Maintenance
* **deps:** update non-major dependencies [#11](https://git.personal.ksv741.ru/WealthLedger/backend/issues/11) ([449cfcd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/449cfcd6c5a91307012bb3a640c8255ba243cbf5))
## [2.2.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.1.0...v2.2.0) (2026-10-18)
### Features
* unify income payment cards and drop period comparisons (WealthLedger/main[#33](https://git.personal.ksv741.ru/WealthLedger/backend/issues/33)) ([#83](https://git.personal.ksv741.ru/WealthLedger/backend/issues/83)) ([31b0e15](https://git.personal.ksv741.ru/WealthLedger/backend/commit/31b0e1557978019a9293e5fd3efeee319194cddd))
### Bug Fixes
* align password policy to 8-72 printable ASCII on register (WealthLedger/backend[#60](https://git.personal.ksv741.ru/WealthLedger/backend/issues/60)) ([#109](https://git.personal.ksv741.ru/WealthLedger/backend/issues/109)) ([e03fad6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e03fad622bfec0fa7e42d536f65e361d319a1ac9))
* restore screener OpenAPI constraints (WealthLedger/backend[#70](https://git.personal.ksv741.ru/WealthLedger/backend/issues/70)) ([#95](https://git.personal.ksv741.ru/WealthLedger/backend/issues/95)) ([2ad3517](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2ad3517d87bffa8af9285d72fd6f78b96b3eed9f))
* separate T-Bank RPC network time from queue wait (WealthLedger/backend[#81](https://git.personal.ksv741.ru/WealthLedger/backend/issues/81)) ([#94](https://git.personal.ksv741.ru/WealthLedger/backend/issues/94)) ([0d7839c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/0d7839cf79669768bba55f420ed43304545ac8d9))
* skip hourly terms overlapped by a long walk (WealthLedger/backend[#54](https://git.personal.ksv741.ru/WealthLedger/backend/issues/54)) ([6975853](https://git.personal.ksv741.ru/WealthLedger/backend/commit/6975853656698b61781ca056f35c0d68e47ea32a))
* surface durable hourly attempt in accounts and income meta (WealthLedger/backend[#58](https://git.personal.ksv741.ru/WealthLedger/backend/issues/58)) ([14bcb9d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/14bcb9da5ff081710d672d140175d348a03a2fe5))
* translate permanent invalid-token errors on online entrypoints (WealthLedger/backend[#59](https://git.personal.ksv741.ru/WealthLedger/backend/issues/59)) ([#110](https://git.personal.ksv741.ru/WealthLedger/backend/issues/110)) ([8a6f60d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/8a6f60d9651fc7c474dd9b3690314505e18c4fa0))
### Performance
* batch bank operation page relations (WealthLedger/backend[#68](https://git.personal.ksv741.ru/WealthLedger/backend/issues/68)) ([285dd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/285dd08472d9acb66fab8d46d511d9ce607b110f))
* start bond search prices with details (WealthLedger/backend[#71](https://git.personal.ksv741.ru/WealthLedger/backend/issues/71)) ([#89](https://git.personal.ksv741.ru/WealthLedger/backend/issues/89)) ([da2e1d0](https://git.personal.ksv741.ru/WealthLedger/backend/commit/da2e1d0781d23f4ab1c38e7bf3cad2e254d026d5))
### Refactoring
* extract shared public instrument projection ([#69](https://git.personal.ksv741.ru/WealthLedger/backend/issues/69)) ([#84](https://git.personal.ksv741.ru/WealthLedger/backend/issues/84)) ([9b7e5e1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9b7e5e1215b7c1c5320effb6ba5b2c2a8b0cd604))
* remove unreachable catalog producer and sync orchestration (WealthLedger/backend[#62](https://git.personal.ksv741.ru/WealthLedger/backend/issues/62)) ([#98](https://git.personal.ksv741.ru/WealthLedger/backend/issues/98)) ([96d4de4](https://git.personal.ksv741.ru/WealthLedger/backend/commit/96d4de4195b105d64ac6ffaf8d3d8aab4142ece4))
### CI
* validate edited PR titles (WealthLedger/backend[#74](https://git.personal.ksv741.ru/WealthLedger/backend/issues/74)) ([bbb5aff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bbb5afff842720374feecfcd74eea83283e0b2ae))
### Tests
* await portfolio publication done instead of polling ticks (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#103](https://git.personal.ksv741.ru/WealthLedger/backend/issues/103)) ([7023021](https://git.personal.ksv741.ru/WealthLedger/backend/commit/702302126b81335959be2e66b4b5ad38e5806b70))
* cover auth token, cookie and expiry boundaries (WealthLedger/backend[#61](https://git.personal.ksv741.ru/WealthLedger/backend/issues/61)) ([#113](https://git.personal.ksv741.ru/WealthLedger/backend/issues/113)) ([32622c1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/32622c1dad9649842d8cb78d8e964da33e5df459))
* isolate HTTP suites from scheduler and bank-card case order (WealthLedger/backend[#65](https://git.personal.ksv741.ru/WealthLedger/backend/issues/65)) ([#100](https://git.personal.ksv741.ru/WealthLedger/backend/issues/100)) ([9e4f9d9](https://git.personal.ksv741.ru/WealthLedger/backend/commit/9e4f9d9cab12aa714449b99280894a521c3514b6))
* replace late portfolio sleep with started and done barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#101](https://git.personal.ksv741.ru/WealthLedger/backend/issues/101)) ([7bc7d8d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/7bc7d8ddecc6cb9f387d00be68f61eb99d57d6fc))
* replace metadata sleeps with upstream start barriers (WealthLedger/backend[#63](https://git.personal.ksv741.ru/WealthLedger/backend/issues/63)) ([#102](https://git.personal.ksv741.ru/WealthLedger/backend/issues/102)) ([ec6d9a2](https://git.personal.ksv741.ru/WealthLedger/backend/commit/ec6d9a2639f1845674bf7ce5fc357f2f7b7f5dfb))
* slim sqlite runner regression workload (WealthLedger/backend[#72](https://git.personal.ksv741.ru/WealthLedger/backend/issues/72)) ([1033d21](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1033d2114e99232a90c5d5c321749a9c6e3fbff0))
### Maintenance
* adopt linear git model in backend docs ([#111](https://git.personal.ksv741.ru/WealthLedger/backend/issues/111)) ([4f485ab](https://git.personal.ksv741.ru/WealthLedger/backend/commit/4f485ab0e9f0f9d52ae446e51966525473948b0e))
* align SQLite driver version (WealthLedger/backend[#67](https://git.personal.ksv741.ru/WealthLedger/backend/issues/67)) ([203cc72](https://git.personal.ksv741.ru/WealthLedger/backend/commit/203cc72cb2d1aac34f39c5ba528820b511666f17))
* drop unused direct source-map-support dependency (WealthLedger/backend[#4](https://git.personal.ksv741.ru/WealthLedger/backend/issues/4)) ([#96](https://git.personal.ksv741.ru/WealthLedger/backend/issues/96)) ([c789e33](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c789e33c41281f2fc3de8851f21ae897a81e2b5d))
* make OpenAPI export atomic and add read-only check (WealthLedger/backend[#3](https://git.personal.ksv741.ru/WealthLedger/backend/issues/3)) ([#97](https://git.personal.ksv741.ru/WealthLedger/backend/issues/97)) ([a93f2d5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a93f2d55e7a31515ed1cc056cd57101f2b315405))
* unify verification preparation (WealthLedger/backend[#66](https://git.personal.ksv741.ru/WealthLedger/backend/issues/66)) ([b90cedd](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b90cedd580321fb560a4ad0d9f9f5ba03303248e))
## [2.1.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.0.0...v2.1.0) (2026-10-12)
### Features
+6 -5
View File
@@ -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
+54 -22
View File
@@ -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 |
| --- | --- |
@@ -297,35 +307,57 @@ Durability оставляет временные DB, логи и `result.json`
Репозиторий выпускается независимо. `develop` — интеграция, `main` — принятые
релизы. Обычная работа: issue-linked ветка от актуального `develop`, PR в `develop`;
общие правила — в Engineering Workflow по ссылке из AGENTS.
Обычные PR допускают squash; release и обратная синхронизация сохраняют историю.
PR сливается squash или rebase-merge; merge-коммит не используется. `main`
обновляется только fast-forward из `develop`, поэтому история линейна:
```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`. Убедись, что
`develop` содержит последний релиз. Создай `release/<name>` от проверенного
`develop`; `nvm use --silent`, `npm ci`. Hotfix начинается от `main` в
`release/hotfix-<name>` и проходит тот же процесс.
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 не включены.
+2 -11
View File
@@ -54,9 +54,6 @@
}
},
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 1
}
@@ -95,11 +92,8 @@
}
},
"src/modules/investments/investment-sync-plan.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 4
"count": 3
}
},
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
@@ -142,11 +136,8 @@
}
},
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/cognitive-complexity": {
"count": 1
},
"sonarjs/no-nested-conditional": {
"count": 7
"count": 6
}
},
"test/bank-cards-durability.cjs": {
+25 -3
View File
@@ -37,7 +37,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 +56,7 @@ export default defineConfig(
'after',
{ overrides: { '?': 'before', ':': 'before' } },
],
'import-x/no-cycle': 'error',
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
},
},
@@ -157,13 +158,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 +213,24 @@ export default defineConfig(
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
},
},
{
files: ['src/**/*.ts'],
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
rules: {
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
'import-x/no-restricted-paths': ['error', {
zones: [{
target: './src',
from: './src/modules/investments/tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
message: 'Use tbank-sync.api for cross-module access.',
}],
}],
},
},
);
+1043 -1314
View File
File diff suppressed because it is too large Load Diff
+54 -45
View File
@@ -1,20 +1,21 @@
{
"name": "wealth-ledger-backend",
"version": "2.1.0",
"version": "2.4.0",
"description": "",
"author": "",
"private": true,
"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:debug": "NODE_ENV=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,93 +32,102 @@
"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": {
@@ -144,7 +154,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,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")
);
+14
View File
@@ -186,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
View File
@@ -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"
+40 -10
View File
@@ -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;
});
+55 -2
View File
@@ -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;
+5
View File
@@ -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);
@@ -8,19 +8,27 @@ 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 -1
View File
@@ -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;
+1 -1
View File
@@ -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;
@@ -7,10 +7,10 @@ import type {
TbankInstrument as MetadataRow,
Prisma,
} from '../../../generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service';
import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service';
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
import { TbankErrorCode } from './tbank-error-code';
const FIRST_BATCH_OFFSET = 0;
@@ -131,11 +131,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 = {
+24 -19
View File
@@ -25,6 +25,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(
@@ -62,11 +78,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 = {
@@ -121,19 +133,11 @@ export class TbankAccessService {
return work ? this.tokens.decrypt(work.connection.encryptedToken) : null;
}
async onlineToken(userId: string) {
const connection = await this.prisma.tbankConnection.findUnique({
where: { userId },
select: { encryptedToken: true, invalidToken: true },
});
if (!connection) throw new ConflictException({ code: TbankErrorCode.NotConnected });
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return this.tokens.decrypt(connection.encryptedToken);
}
async current(userId: string): Promise<{ revision: string; token: string }> {
// 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 },
@@ -142,7 +146,8 @@ export class TbankAccessService {
if (connection.invalidToken) {
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
}
return { revision: connection.revision, token: this.tokens.decrypt(connection.encryptedToken) };
const token = this.tokens.decrypt(connection.encryptedToken);
return this.tokens.translateInvalidToken(() => run({ revision: connection.revision, token }));
}
async publishMembership(
+1
View File
@@ -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';
+76 -1
View File
@@ -1,5 +1,6 @@
import { status } from '@grpc/grpc-js';
import { Metadata, status } from '@grpc/grpc-js';
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import { 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,
+72 -10
View File
@@ -2,6 +2,7 @@ import {
ChannelCredentials,
Client,
ClientUnaryCall,
connectivityState,
loadPackageDefinition,
Metadata,
ServiceError,
@@ -60,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 = {
@@ -367,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,
@@ -397,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,
@@ -506,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),
});
@@ -537,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),
@@ -546,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),
@@ -558,7 +595,7 @@ export class TbankClient {
}
if (
grpcCode === status.NOT_FOUND &&
(method === 'shareBy' || metadataCall)
(notFound || method === 'shareBy' || metadataCall)
) {
throw new NotFoundException();
}
@@ -573,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
View File
@@ -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,6 +2,8 @@ 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)),
@@ -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();
+22 -67
View File
@@ -7,17 +7,15 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto';
import { Session, User } from '../../../../generated/prisma/client';
import type { 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 { 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 +26,7 @@ const DUMMY_PASSWORD_HASH =
@Injectable()
export class AuthService {
constructor(
private readonly prisma: PrismaService,
private readonly repository: AuthRepository,
private readonly jwt: JwtService,
) {}
@@ -36,27 +34,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 +54,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 +79,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 +87,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 +108,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 +123,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 +131,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 +152,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 +169,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)
);
}
+2 -1
View File
@@ -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 '../../../prisma/prisma-error-code';
import { PrismaService } from '../../../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)
);
}
@@ -22,9 +22,9 @@ import {
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code';
import { AuthService } from '../application/auth.service';
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { CredentialsDto } from './auth.dto';
import { LoginDto, RegisterDto } from './auth.dto';
import { JwtAuthGuard } from './jwt-auth.guard';
const REFRESH_COOKIE = 'refresh_token';
@@ -56,7 +56,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 +69,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 +134,7 @@ export class AuthController {
sameSite: 'lax' as const,
secure: process.env['NODE_ENV'] !== 'development',
path: COOKIE_PATH,
maxAge: REFRESH_TTL_MS,
};
}
}
+28 -10
View File
@@ -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;
}
@@ -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,10 +1,10 @@
import { GoneException, Injectable } from '@nestjs/common';
import {
import type {
BankCardOperation,
Prisma,
} from '../../../../../generated/prisma/client';
import { PrismaService } from '../../../../prisma/prisma.service';
import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind';
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
@@ -28,15 +28,10 @@ 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,
@@ -51,21 +46,14 @@ export class BankOperationsService {
: null;
const categoryId = source?.categoryId ?? row.categoryId;
const category = categoryId
? await tx.expenseCategory.findFirst({
where: { id: categoryId, userId: row.userId },
})
? await this.repository.category(tx, row.userId, categoryId)
: 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 } } : {}),
},
})
: [];
const refunds = [] as Awaited<ReturnType<BankCardsRepository['refunds']>>;
if (row.kind === BankOperationKind.Expense || source) {
refunds.push(...await this.repository.refunds(
tx, row.userId, source?.id ?? row.id, source?.id ? row.id : undefined,
));
}
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
@@ -91,6 +79,57 @@ export class BankOperationsService {
};
}
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
? refunds.filter(
(refund) =>
refund.originalExpenseId === (source?.id ?? row.id) &&
(!source || refund.id !== row.id),
)
: [];
return {
id: row.id,
kind: row.kind as BankOperationDto['kind'],
cardId: row.cardId,
cardName: card.name,
targetCardId: row.targetCardId,
targetCardName: target?.name ?? null,
categoryId,
categoryName: categoriesById.get(categoryId ?? '')?.name ?? null,
originalExpenseId: row.originalExpenseId,
amount: rubMoney(row.amountKopecks),
availableRefund:
row.kind === BankOperationKind.Expense || source
? rubMoney(
(source?.amountKopecks ?? row.amountKopecks) -
relatedRefunds.reduce(
(sum, refund) => sum + refund.amountKopecks,
ZERO_KOPECKS,
),
)
: null,
date: row.date,
description: row.description,
version: row.version,
createdAt: row.createdAt.toISOString(),
};
});
}
private async validateOperation(
tx: Tx,
userId: string,
@@ -132,16 +171,12 @@ export class BankOperationsService {
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();
}
@@ -163,29 +198,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 +213,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 +230,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 +246,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 +279,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 +301,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 +311,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 +332,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,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { Prisma } from '../../../../../generated/prisma/client';
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
@@ -9,10 +9,15 @@ import {
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 +37,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 {
@@ -2,12 +2,39 @@ import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../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,
@@ -21,6 +48,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 {
@@ -33,6 +33,7 @@ describe('saved brokerage account projection', () => {
});
it('keeps exact saved totals and independent missing summaries', async () => {
const structure = { currencies: [], unvaluedCategories: [] };
portfolio.saved.mockResolvedValue({
membership: meta,
accounts: [
@@ -43,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' }],
},
},
{
@@ -61,15 +64,91 @@ describe('saved brokerage account projection', () => {
});
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('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({
@@ -1,10 +1,82 @@
import { Injectable } from '@nestjs/common';
import { createHash } from 'node:crypto';
import {
decimalFromString,
divide,
formatDecimal,
isZero,
multiply,
} from '../../../common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper';
import { PortfolioService } from '../portfolio/portfolio.api';
import { TbankSyncRepository } from '../tbank-sync/tbank-sync.repository';
import { BrokerageAccountDto, BrokerageAccountsResourceDto } 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 {
@@ -26,12 +98,20 @@ 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,
}));
@@ -61,6 +141,7 @@ export class BrokerageAccountsService {
: null,
freshForMs: 3_600_000,
work: null,
lastAttempt: hourlyAttempt(hourly, ['membership', 'portfolio']),
dependencies: saved.accounts.map((account) => account.meta),
}),
sync: hourly
@@ -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 })
@@ -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: 'temporarily-unavailable',
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,7 +6,6 @@ import {
compare,
decimalFromString,
formatDecimal,
subtract,
} from '../../../common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar';
@@ -18,7 +17,6 @@ import {
import {
IncomeAnalyticsEntryDto,
IncomeAnalyticsResourceDto,
IncomeComparisonDto,
IncomeCurrencySummaryDto,
IncomeDependencyDto,
IncomeInstrumentDto,
@@ -47,14 +45,11 @@ export class IncomeAnalyticsService {
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];
},
() => [plan.selected],
observe,
);
if (selected.items === null) return { data: null, meta: selected.meta };
@@ -77,44 +72,17 @@ export class IncomeAnalyticsService {
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] : []),
];
},
() => [plan.selected],
observe,
);
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}`,
@@ -127,7 +95,10 @@ export class IncomeAnalyticsService {
? 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,
}),
@@ -147,14 +118,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),
@@ -198,38 +161,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,
@@ -293,22 +224,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', () => {
@@ -8,51 +8,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 +119,6 @@ function endOfMonth(value: string): string {
return new Date(Date.UTC(year, month, PREVIOUS_MONTH_LAST_DAY)).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
}
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 });
}
@@ -3,17 +3,11 @@ 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,
type TbankBond,
TbankClient,
type TbankShare,
tbankSourceTtlMs,
type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code';
@@ -28,46 +22,27 @@ 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 MISSING_DATA_REVISION = '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) {
async read(userId: string, input: CatalogQuery) {
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({
@@ -83,7 +58,7 @@ export class CatalogDiscoveryService {
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input, observe);
const discovery = await this.read(userId, input);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
@@ -147,11 +122,11 @@ export class CatalogDiscoveryService {
observe = false,
) {
if (!observe) {
const token = await this.access.onlineToken(userId);
const instrument =
const instrument = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
? this.tbank.getShare(token, uid)
: this.tbank.getBond(token, uid),
);
if (!instrument) throw new NotFoundException();
return {
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
@@ -183,11 +158,11 @@ export class CatalogDiscoveryService {
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,
);
const alias = await this.read(userId, {
instrumentType,
query: instrument.ticker,
exactTicker: true,
});
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
@@ -226,11 +201,11 @@ export class CatalogDiscoveryService {
) {
if (!observe) {
const query = ticker.trim().toUpperCase();
const token = await this.access.onlineToken(userId);
const found =
const found = await this.access.online(userId, ({ token }) =>
instrumentType === 'share'
? await this.tbank.findShares(token, query)
: await this.tbank.findBonds(token, query);
? this.tbank.findShares(token, query)
: this.tbank.findBonds(token, query),
);
const matches = selectCatalogInstruments(
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
query,
@@ -248,11 +223,11 @@ export class CatalogDiscoveryService {
const result = await this.detail(userId, instrumentType, matches[0].uid);
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
}
const lookup = await this.read(
userId,
{ instrumentType, query: ticker, exactTicker: true },
observe,
);
const lookup = await this.read(userId, {
instrumentType,
query: ticker,
exactTicker: true,
});
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({
@@ -293,28 +268,32 @@ export class CatalogDiscoveryService {
}
private async onlineSearch(userId: string, input: CatalogQuery) {
const token = await this.access.onlineToken(userId);
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
const instruments = selectCatalogInstruments(
input.exactTicker
? found.filter((item) => item.ticker?.trim().toUpperCase() === input.query)
: found,
input.query,
input.instrumentType,
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
const details = await Promise.all(
instruments.map((item) =>
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
const found =
input.instrumentType === 'share'
? this.tbank.getShare(token, item.uid)
: this.tbank.getBond(token, item.uid),
),
);
const prices = instruments.length
? await this.tbank.getLastPrices(token, instruments.map((item) => item.uid))
: [];
? 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);
@@ -336,196 +315,4 @@ export class CatalogDiscoveryService {
}),
};
}
request(
userId: string,
revision: string,
query: CatalogQuery,
force = false,
) {
const input = catalogQuery(query);
const reserved = this.reservations.then(async () => {
const saved = await this.repository.read(input);
if (
!force &&
saved.dataRevision !== MISSING_DATA_REVISION
) return null;
const reservation = await this.repository.reserve(
{ userId, revision },
input,
this.runId,
this.active,
);
if (!reservation) return null;
const { id, attemptId } = reservation.work;
if (!reservation.launch) {
const current = this.producers.get(catalogScope(input));
if (current) current.force ||= force;
return id;
}
const attempt = { userId, revision, id, attemptId };
this.active.add(attemptId);
let resolve = () => {};
const done = new Promise<void>((finish) => {
resolve = finish;
});
this.completions.set(attemptId, { workId: id, done, resolve });
const key = catalogScope(input);
const producer = this.producers.get(key);
if (producer) {
producer.observers.push(attempt);
producer.force ||= force;
} else {
const queued = { input, attempt, observers: [], force };
this.producers.set(key, queued);
this.queue.push(queued);
}
if (!this.running) {
this.running = true;
setImmediate(() => {
void this.drain();
});
}
return id;
});
this.reservations = reserved.catch(() => undefined);
return reserved;
}
async refresh(userId: string, revision: string, query: CatalogQuery) {
const id = await this.request(userId, revision, query, true);
if (id) {
await Promise.all(
[...this.completions.values()]
.filter((entry) => entry.workId === id)
.map((entry) => entry.done),
);
}
const saved = await this.repository.read(catalogQuery(query));
const sources = await this.enrichment.readMany(
userId,
query.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
true,
);
return [
...(id ? [id] : []),
...sources.flatMap((source) =>
source.meta.sync.id ? [source.meta.sync.id] : [],
),
];
}
private async drain() {
try {
while (this.queue.length) {
const producer = this.queue.shift();
if (!producer) break;
const { input, attempt } = producer;
let success = false;
try {
const token = await this.access.tokenForAttempt(attempt);
if (token) {
const found =
input.instrumentType === 'share'
? await this.tbank.findShares(token, input.query)
: await this.tbank.findBonds(token, input.query);
success = await this.repository.publish(
attempt,
input,
selectCatalogInstruments(
input.exactTicker
? found.filter(
(item) =>
item.ticker?.trim().toUpperCase() === input.query,
)
: found,
input.query,
input.instrumentType,
).slice(
FIRST_RESULT_OFFSET,
input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit,
),
new Date(),
);
}
} catch (error) {
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
await this.access.rejectMembershipToken(attempt);
} else {
this.logger.event('warn', 'catalog-discovery.failed', {
code: TbankErrorCode.Unavailable,
});
}
}
if (success) {
const saved = await this.repository.read(input);
for (const observer of [attempt, ...producer.observers]) {
if (!input.exactTicker || saved.data?.length === UNAMBIGUOUS_MATCH_COUNT) {
await this.enrichment.requestMany(
observer.userId,
observer.revision,
input.instrumentType,
saved.data?.map((item) => item.uid) ?? [],
producer.force,
input.exactTicker ? ['detail'] : ['last-price', 'detail'],
);
}
}
}
this.producers.delete(catalogScope(input));
for (const observer of [attempt, ...producer.observers]) {
await this.repository.finish(observer, success);
this.active.delete(observer.attemptId);
this.completions.get(observer.attemptId)?.resolve();
this.completions.delete(observer.attemptId);
}
}
} catch {
for (const completion of this.completions.values()) completion.resolve();
this.completions.clear();
this.active.clear();
this.producers.clear();
this.queue.length = 0;
this.logger.event('error', 'catalog-discovery.persistence-failed', {
code: TbankErrorCode.Unavailable,
});
} finally {
this.running = false;
}
}
}
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -38,6 +38,7 @@ import {
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
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;
@@ -109,6 +110,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 +124,7 @@ export class CatalogEnrichmentService {
false,
kinds,
range,
refreshBefore,
);
}
const work = inputs.length
@@ -174,6 +177,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 () => {
@@ -188,7 +192,9 @@ export class CatalogEnrichmentService {
);
const policy = sourcePolicy(input.kind);
const needsRefresh = force || !row?.fetchedAt ||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) ||
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
if (!needsRefresh) {
continue;
}
@@ -230,7 +236,6 @@ export class CatalogEnrichmentService {
return reservation;
}
// eslint-disable-next-line sonarjs/cognitive-complexity -- Section composition owns the source dependency policy.
async section(
userId: string,
instrumentType: 'bond' | 'share',
@@ -239,18 +244,20 @@ export class CatalogEnrichmentService {
observe = false,
) {
if (section === 'company' && !observe) {
const token = await this.access.onlineToken(userId);
const instrument =
instrumentType === 'share'
? await this.tbank.getShare(token, uid)
: await this.tbank.getBond(token, uid);
if (!instrument) throw new NotFoundException();
const [description, fundamentals] = instrument.assetUid
? await Promise.all([
this.tbank.getAssetDescription(token, instrument.assetUid),
this.tbank.getFundamentals(token, instrument.assetUid),
])
: [null, null];
const { 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()}`,
@@ -651,39 +658,6 @@ export type CatalogPayload = {
tradingStatus?: null | string;
};
function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
function sourcePolicy(kind: CatalogSource['kind']) {
if (
[
@@ -696,6 +670,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
) {
return 'quote';
}
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
return 'stable';
}
function producerKey(input: CatalogSource) {
@@ -22,6 +22,8 @@ const row = (
isin: null,
ticker: null,
logoName: null,
sector: null,
issuer: null,
});
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
positions,
@@ -0,0 +1,64 @@
import type {
TbankBond,
TbankShare,
} from '../../../../integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument';
const share = {
uid: 'share-uid',
figi: 'FIGI1',
assetUid: 'asset-1',
name: 'Share',
ticker: 'SHAR',
isin: 'US000',
classCode: 'TQBR',
realExchange: 'REAL_EXCHANGE_MOEX',
apiTradeAvailableFlag: true,
currency: 'rub',
shareType: 'SHARE_TYPE_COMMON',
sector: 'Finance',
lot: 1,
brand: { logoName: 'logo.png', title: 'Brand', id: 'brand-1' },
first_1dayCandleDate: '2020-01-01T00:00:00Z',
privateField: 'must-not-leak',
} as unknown as TbankShare;
const bond = {
uid: 'bond-uid',
figi: 'FIGI2',
assetUid: 'asset-2',
name: 'Bond',
ticker: 'BOND',
classCode: 'TQOB',
currency: 'rub',
lot: 1,
nominal: { value: '1000', currency: 'rub' },
initialNominal: { value: '1000', currency: 'rub' },
aciValue: { value: '10', currency: 'rub' },
maturityDate: '2030-01-01T00:00:00Z',
couponQuantityPerYear: 2,
bondType: 'BOND_TYPE_EXPIRING',
brand: null,
privateField: 'must-not-leak',
} as unknown as TbankBond;
describe('publicInstrument projection', () => {
it('keeps approved share fields, trims brand, drops private extras', () => {
const result = publicInstrument(share);
expect(result.uid).toBe('share-uid');
expect(result.shareType).toBe('SHARE_TYPE_COMMON');
expect(result.brand).toEqual({ logoName: 'logo.png' });
expect(result).not.toHaveProperty('privateField');
expect(result).not.toHaveProperty('brand.title');
});
it('projects bonds with missing optional fields and null brand', () => {
const result = publicInstrument(bond);
expect(result.nominal).toEqual({ value: '1000', currency: 'rub' });
expect(result.shareType).toBeUndefined();
expect(result.brand).toBeUndefined();
expect(result.isin).toBeUndefined();
expect(result).not.toHaveProperty('privateField');
});
});
@@ -0,0 +1,38 @@
import type {
TbankBond,
TbankShare,
} from '../../../../integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond;
const share = value as TbankShare;
return {
uid: value.uid,
figi: value.figi,
assetUid: value.assetUid,
name: value.name,
ticker: value.ticker,
isin: value.isin,
classCode: value.classCode,
realExchange: value.realExchange,
apiTradeAvailableFlag: value.apiTradeAvailableFlag,
currency: value.currency,
shareType: share.shareType,
sector: value.sector,
lot: value.lot,
brand: value.brand ? { logoName: value.brand.logoName } : undefined,
first_1dayCandleDate: value.first_1dayCandleDate,
nominal: bond.nominal,
initialNominal: bond.initialNominal,
aciValue: bond.aciValue,
maturityDate: bond.maturityDate,
callDate: bond.callDate,
couponQuantityPerYear: bond.couponQuantityPerYear,
floatingCouponFlag: bond.floatingCouponFlag,
perpetualFlag: bond.perpetualFlag,
amortizationFlag: bond.amortizationFlag,
subordinatedFlag: bond.subordinatedFlag,
bondType: bond.bondType,
riskLevel: bond.riskLevel,
};
}
@@ -3,9 +3,9 @@ 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 { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service';
const UNCHANGED_REVISION_INCREMENT = 0;
@@ -293,11 +293,7 @@ export class CatalogDiscoveryRepository {
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 = {
@@ -1,6 +1,5 @@
import { Module } from '@nestjs/common';
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
import { TbankModule } from '../../../integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module';
@@ -21,7 +20,6 @@ import { StockController } from './stocks/presentation/stock.controller';
controllers: [StockController, BondController],
exports: [StockIncomeService, BondIncomeService],
providers: [
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
BondIncomeService,
BondMarketService,
GetStockCompanyUseCase,
@@ -1,3 +1,7 @@
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
TbankAccessService: class {},
}));
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
@@ -22,19 +26,21 @@ function share(index: number, overrides: Partial<TbankShare> = {}): TbankShare {
}
describe('StockScreenerService', () => {
const access = { current: jest.fn(), local: jest.fn() };
const access = { online: jest.fn(), local: jest.fn() };
const client = { getAllShares: jest.fn() };
const logger = { event: jest.fn() };
let service: StockScreenerService;
beforeEach(() => {
jest.clearAllMocks();
access.current.mockResolvedValue({ revision, token });
access.online.mockImplementation((_userId: string, run: (connection: { revision: string; token: string }) => Promise<unknown>) =>
run({ revision, token }),
);
access.local.mockResolvedValue({ revision });
client.getAllShares.mockResolvedValue(
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
);
service = new StockScreenerService(access, client as never, logger as never);
service = new StockScreenerService(access as never, client as never, logger as never);
});
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
@@ -1,6 +1,5 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
ServiceUnavailableException,
@@ -10,6 +9,7 @@ import { randomUUID } from 'node:crypto';
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import {
selectStockScreenerInstruments,
@@ -47,24 +47,22 @@ type StockScreenerPage = {
searchId: string;
};
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
type TbankAccess = {
current(userId: string): Promise<{ revision: string; token: string }>;
local(userId: string): Promise<{ revision: string }>;
};
@Injectable()
export class StockScreenerService {
private readonly snapshots = new Map<string, Snapshot>();
constructor(
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
private readonly access: TbankAccessService,
private readonly client: TbankClient,
private readonly logger: StructuredLogger,
) {}
async search(userId: string, conditions: StockScreenerConditions): Promise<StockScreenerPage> {
const connection = await this.access.current(userId);
const instruments = await this.client.getAllShares(connection.token);
const { instruments, revision } = await this.access.online(userId, async (connection) => ({
instruments: await this.client.getAllShares(connection.token),
revision: connection.revision,
}));
const asOf = new Date().toISOString();
const selected = selectStockScreenerInstruments({ asOf: new Date(asOf), instruments }, conditions)
.instruments.map((instrument) => this.item(instrument));
@@ -74,7 +72,7 @@ export class StockScreenerService {
asOf,
expiresAt: Date.now() + SNAPSHOT_TTL_MS,
items: selected,
revision: connection.revision,
revision,
userId,
});
this.logger.event('info', 'stock.screener.search.completed', { asOf, count: selected.length, operation: 'stock-screener-search' });
@@ -91,6 +91,7 @@ export class StockController {
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
criteria: StockScreenerCriteriaDto,
) {
// The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them.
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
throw new BadRequestException();
@@ -93,7 +93,7 @@ export class StockScreenerItemDto {
@ApiProperty({ nullable: true, type: String }) declare currency: null | string;
@ApiProperty({ nullable: true, type: String }) declare realExchange: null | string;
@ApiProperty({ nullable: true, type: String }) declare countryOfRisk: null | string;
@ApiProperty({ nullable: true, type: 'integer' }) declare lot: null | number;
@ApiProperty({ nullable: true, minimum: MIN_TICKER_LENGTH, type: 'integer' }) declare lot: null | number;
}
export class StockScreenerPageDto {
@@ -1,187 +1,16 @@
import {
accountHistory,
ledgerRange,
planInvestmentScreen,
} from './investment-sync-plan';
import { accountHistory, ledgerRange } from './investment-sync-plan';
describe('screen dependency and history planning', () => {
// Missing opening dates must not silently narrow the reported history boundary.
describe('account history boundaries', () => {
const now = new Date('2026-09-25T12:00:00Z');
const context = {
membershipKnown: true,
accounts: [{ accountId: 'a', openedOn: '2020-01-01' }],
instruments: [
{
uid: 'uid',
type: 'STOCK' as const,
historyFrom: new Date('2020-01-01Z'),
},
],
};
it('prioritizes discovery, selected/comparisons, then history; Moscow dates are converted once', () => {
const plan = planInvestmentScreen(
{
screen: 'income',
accountId: 'a',
from: '2024-02-01',
to: '2024-02-29',
},
context,
now,
);
expect(plan.sources[0].kind).toBe('membership');
const selected = plan.sources.filter(
(source) => source.kind === 'operations' && source.priority === 'screen',
);
expect(
selected.some(
(source) => source.from?.toISOString() === '2024-01-31T21:00:00.000Z',
),
).toBe(true);
expect(
selected.some(
(source) => source.to?.toISOString() === '2024-02-29T21:00:00.000Z',
),
).toBe(true);
expect(plan.unavailable).toEqual([
{ resourceKey: 'income/yearAgo', reason: 'calendar_or_account_boundary' }, // gitleaks:allow public resource identifier
]);
const firstHistory = plan.sources.findIndex(
(source) => source.priority === 'history',
);
expect(
plan.sources
.slice(firstHistory)
.every((source) => source.priority === 'history'),
).toBe(true);
});
it('force contains only shown dependencies and retains instrument reconciliation predicates', () => {
const plan = planInvestmentScreen(
{
screen: 'stock',
instrumentUid: 'uid',
from: '2026-01-01',
to: '2026-02-01',
candlePeriod: 'year',
},
context,
now,
true,
);
expect(plan.sources.map((source) => source.kind)).toEqual(
expect.arrayContaining([
'membership',
'portfolio',
'operations',
'metadata',
'quote',
'company',
'calendar',
'candles',
]),
);
expect(plan.sources.every((source) => source.priority !== 'history')).toBe(
true,
);
expect(
plan.sources
.filter((source) => source.kind === 'operations')
.every((source) => source.instrumentUid === 'uid'),
).toBe(true);
const accounts = planInvestmentScreen(
{ screen: 'accounts' },
context,
now,
true,
);
expect(
accounts.sources.some((source) => source.kind === 'operations'),
).toBe(false);
});
it('never invents an account or candle history boundary; discovery gates unknown membership', () => {
const unknown = {
...context,
accounts: [{ accountId: 'a', openedOn: null }],
instruments: [],
};
expect(accountHistory(unknown.accounts, now)[0].from.toISOString()).toBe(
const accounts = [{ accountId: 'a', openedOn: null }];
it('retains the unknown opening boundary for history and instrument ledger', () => {
expect(accountHistory(accounts, now)[0].from.toISOString()).toBe(
'1970-01-01T00:00:00.000Z',
);
expect(
ledgerRange(
undefined,
undefined,
'uid',
unknown.accounts,
now,
).from.toISOString(),
ledgerRange(undefined, undefined, 'uid', accounts, now).from.toISOString(),
).toBe('1970-01-01T00:00:00.000Z');
const plan = planInvestmentScreen(
{ screen: 'bond', instrumentUid: 'uid', candlePeriod: 'all' },
unknown,
now,
);
expect(plan.sources.some((source) => source.kind === 'candles')).toBe(
false,
);
expect(plan.unavailable[0].reason).toBe('history_start_unavailable');
expect(
planInvestmentScreen(
{ screen: 'account' },
{ ...context, membershipKnown: false },
now,
).sources,
).toEqual([{ kind: 'membership', priority: 'discovery' }]);
});
it('keeps UTC event days separate from Moscow instrument calendar days at year rollover', () => {
const rollover = new Date('2026-12-31T22:00:00Z');
const events = planInvestmentScreen(
{ screen: 'events' },
context,
rollover,
true,
);
expect(
events.sources.find((source) => source.kind === 'calendar')?.from,
).toEqual(new Date('2026-12-31Z'));
const stock = planInvestmentScreen(
{ screen: 'stock', instrumentUid: 'uid' },
context,
rollover,
true,
);
expect(
stock.sources.find((source) => source.kind === 'calendar'),
).toMatchObject({
from: new Date('2022-01-01Z'),
to: new Date('2028-01-01Z'),
});
expect(rollover.toISOString()).toBe('2026-12-31T22:00:00.000Z');
});
it('keeps search queries separate and events scoped to their inclusive period', () => {
const search = planInvestmentScreen(
{ screen: 'stocks', query: ' sber ' },
context,
now,
true,
);
expect(
search.sources.find((source) => source.kind === 'catalog')?.query,
).toBe('SBER');
const events = planInvestmentScreen(
{ screen: 'events', from: '2026-10-01', to: '2026-10-31' },
context,
now,
true,
);
expect(
events.sources.find((source) => source.kind === 'calendar'),
).toMatchObject({
from: new Date('2026-10-01Z'),
to: new Date('2026-10-31Z'),
});
expect(events.sources.some((source) => source.kind === 'operations')).toBe(
false,
);
});
});
+1 -278
View File
@@ -1,288 +1,11 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
import { planIncomePeriod } from './income/income.api';
import { dateOnlyBounds, moscowDate } from './investment-calendar';
import {
type OperationInterval,
operationIntervals,
} from './operations/domain/operations-sync-plan';
import type { OperationInterval } from './operations/domain/operations-sync-plan';
const FORECAST_YEARS = 1;
const JANUARY_INDEX = 0;
const FIRST_DAY_OF_MONTH = 1;
const EXCLUSIVE_END_OFFSET_MS = 1;
const NO_END_OFFSET_MS = 0;
export type InvestmentScreen = {
accountId?: string;
candlePeriod?: 'all' | 'halfYear' | 'month' | 'year';
candleRange?: { from: Date; to: Date };
from?: string;
instrumentUid?: string;
query?: string;
screen:
| 'account' |
'accounts' |
'bond' |
'bonds' |
'events' |
'income' |
'operations' |
'stock' |
'stocks';
to?: string;
};
const defaultEventDays = 30;
const incomeHistoryYears = 5;
const candleMonths = { month: 1, halfYear: 6, year: 12 };
type Account = { accountId: string; openedOn: null | string };
type Instrument = {
historyFrom: Date | null;
type: 'BOND' | 'STOCK';
uid: string;
};
type Source = {
accountId?: string;
from?: Date;
instrumentType?: 'BOND' | 'STOCK';
instrumentUid?: string;
kind:
| 'calendar' |
'candles' |
'catalog' |
'company' |
'membership' |
'metadata' |
'metrics' |
'operations' |
'portfolio' |
'quote';
priority: 'discovery' | 'history' | 'screen';
query?: string;
to?: Date;
};
// Expands screen scope, never credentials or arbitrary caller-supplied jobs.
// Owners execute their own sources and expand instruments after discovery.
export function planInvestmentScreen(
screen: InvestmentScreen,
context: {
accounts: Account[];
instruments: Instrument[];
membershipKnown: boolean;
},
now: Date,
force = false,
) {
const sources: Source[] = [{ kind: 'membership', priority: 'discovery' }];
const accounts = context.accounts.filter(
(account) =>
!screen.accountId ||
screen.accountId === 'all' ||
account.accountId === screen.accountId,
);
const unavailable: { reason: string; resourceKey: string }[] = [];
const operations = (from: Date, to: Date, instrumentUid?: string) => {
const targets = accounts.flatMap((account) =>
from < to
? [
{
accountId: account.accountId,
from,
to,
...(instrumentUid ? { instrumentUid } : {}),
},
]
: [],
);
sources.push(
...operationIntervals(targets).map((range) => ({
...range,
kind: 'operations' as const,
priority: 'screen' as const,
})),
);
};
const portfolio = () =>
sources.push(
...accounts.map(({ accountId }) => ({
kind: 'portfolio' as const,
accountId,
priority: 'screen' as const,
})),
);
const knownOpening =
accounts.length && accounts.every((account) => account.openedOn)
? new Date(
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
`${accounts.map((account) => account.openedOn ?? '').sort()[0]}T00:00:00+03:00`,
)
: undefined;
if (!context.membershipKnown) return { sources, unavailable, now };
if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(screen.screen)
) {
portfolio();
}
if (
screen.screen === 'operations' ||
screen.screen === 'stock' ||
screen.screen === 'bond'
) {
const range = ledgerRange(
screen.from,
screen.to,
screen.instrumentUid,
accounts,
now,
);
operations(range.from, range.to, force ? screen.instrumentUid : undefined);
}
if (screen.screen === 'account' || screen.screen === 'income') {
const period = planIncomePeriod(
screen.screen === 'income' ? screen.from : undefined,
screen.screen === 'income' ? screen.to : undefined,
knownOpening,
);
for (const range of [
period.selected,
...(screen.screen === 'income'
? [period.previous, ...(period.yearAgo ? [period.yearAgo] : [])]
: []),
]) {
const bounds = dateOnlyBounds(range, now);
operations(bounds.from, bounds.to);
}
if (screen.screen === 'income' && !period.yearAgo) {
unavailable.push({
resourceKey: 'income/yearAgo', // gitleaks:allow public resource identifier
reason: 'calendar_or_account_boundary',
});
}
if (screen.screen === 'account') {
const range = ledgerRange(
screen.from,
screen.to,
undefined,
accounts,
now,
);
operations(range.from, range.to);
}
}
if (screen.screen === 'stocks' || screen.screen === 'bonds') {
sources.push({
kind: 'catalog',
instrumentType: screen.screen === 'stocks' ? 'STOCK' : 'BOND',
query: screen.query?.trim().toUpperCase() ?? '',
priority: 'screen',
});
}
const instruments = context.instruments.filter(
(instrument) =>
!screen.instrumentUid || instrument.uid === screen.instrumentUid,
);
if (
screen.instrumentUid &&
!instruments.length &&
(screen.screen === 'stock' || screen.screen === 'bond')
) {
instruments.push({
uid: screen.instrumentUid,
type: screen.screen === 'stock' ? 'STOCK' : 'BOND',
historyFrom: null,
});
}
for (const instrument of instruments) {
const identity = {
instrumentUid: instrument.uid,
instrumentType: instrument.type,
};
sources.push({ ...identity, kind: 'metadata', priority: 'screen' });
if (['bond', 'bonds', 'stock', 'stocks'].includes(screen.screen)) {
sources.push({ ...identity, kind: 'quote', priority: 'screen' });
}
if (screen.screen === 'stock' || screen.screen === 'bond') {
sources.push({
...identity,
kind: screen.screen === 'stock' ? 'company' : 'metrics',
priority: 'screen',
});
const from = new Date(now);
const period = screen.candlePeriod ?? 'month';
if (period !== 'all') {
// eslint-disable-next-line security/detect-object-injection -- The period belongs to the validated candle-period union.
from.setUTCMonth(from.getUTCMonth() - candleMonths[period]);
}
const range =
screen.candleRange ??
(period === 'all'
? instrument.historyFrom
? { from: instrument.historyFrom, to: now }
: null
: { from, to: now });
if (range) {
sources.push({
...identity,
kind: 'candles',
...range,
priority: 'screen',
});
} else {
unavailable.push({
resourceKey: `candles/${instrument.uid}/all`,
reason: 'history_start_unavailable',
});
}
}
if (['account', 'bond', 'events', 'stock'].includes(screen.screen)) {
const day =
screen.screen === 'stock' || screen.screen === 'bond'
? moscowDate(now)
: now.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
const from =
screen.screen === 'events' && screen.from
? new Date(`${screen.from}T00:00:00Z`)
: new Date(`${day}T00:00:00Z`);
const to =
screen.screen === 'events' && screen.to
? new Date(`${screen.to}T00:00:00Z`)
: new Date(from);
if (
!(screen.screen === 'events' && screen.to) &&
screen.screen !== 'stock' &&
screen.screen !== 'bond'
) {
to.setUTCDate(to.getUTCDate() + defaultEventDays);
}
if (screen.screen === 'stock' || screen.screen === 'bond') {
from.setUTCFullYear(from.getUTCFullYear() - incomeHistoryYears);
to.setUTCFullYear(to.getUTCFullYear() + FORECAST_YEARS);
}
sources.push({
...identity,
kind: 'calendar',
from,
to,
priority: 'screen',
});
}
}
if (!force && !['bonds', 'stocks'].includes(screen.screen)) {
sources.push(
...accountHistory(accounts, now).map((range) => ({
...range,
kind: 'operations' as const,
priority: 'history' as const,
})),
);
}
const priorities = { discovery: 0, screen: 1, history: 2 };
sources.sort(
(left, right) => priorities[left.priority] - priorities[right.priority],
);
return { sources, unavailable, now };
}
export function accountHistory(
accounts: Account[],
@@ -0,0 +1,10 @@
export const operationCategories = [
'trade',
'income',
'tax',
'fee',
'transfer',
'other',
] as const;
export type OperationCategory = (typeof operationCategories)[number];
@@ -6,13 +6,11 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
import { accountHistory } from '../../investment-sync-plan';
import {
type OperationInterval,
operationIntervals,
operationsSyncPriority,
planOperationRead,
uncoveredOperationIntervals,
} from '../domain/operations-sync-plan';
import {
BrokerageOperationsRepository,
@@ -48,7 +46,6 @@ export class BrokerageOperationsSyncService {
intervals: OperationInterval[],
options: {
force?: boolean;
history?: boolean;
movingEnd?: boolean;
observe?: boolean;
revision?: string;
@@ -99,30 +96,6 @@ export class BrokerageOperationsSyncService {
connection.revision,
)
: null;
if (!options.observe && !options.force && options.history) {
const accountIds = new Set(
intervals.map((interval) => interval.accountId),
);
const history = accountHistory(
connection.accounts.filter((account) =>
accountIds.has(account.accountId),
),
now,
);
const missing = planOperationRead(history, coverage, now, {
movingEnd: true,
}).load;
const remaining = uncoveredOperationIntervals(missing, targets);
if (remaining.length) {
await this.request(
userId,
remaining,
false,
'history',
connection.revision,
);
}
}
return { plan, handle };
}
@@ -6,21 +6,16 @@ import {
InstrumentMetadataService,
metadataKey,
TbankAccessService,
TbankMembershipService,
type TbankOperationItem,
tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api';
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
accountHistory,
ledgerRange,
planInvestmentScreen,
} from '../../investment-sync-plan';
import { accountHistory, ledgerRange } from '../../investment-sync-plan';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.repository';
} from '../../tbank-sync/tbank-sync.api';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
confirmedOperationRanges,
@@ -37,7 +32,6 @@ import {
} from '../presentation/brokerage-operation.dto';
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
const EXCLUSIVE_END_OFFSET_MS = 1;
const FIRST_RESULT_OFFSET = 0;
const MISSING_MEMBERSHIP_REVISION = 0;
const STALE_FRESHNESS_MS = 0;
@@ -61,7 +55,6 @@ export class BrokerageOperationsService {
private readonly metadata: InstrumentMetadataService,
private readonly repository: BrokerageOperationsRepository,
private readonly sync: BrokerageOperationsSyncService,
private readonly membership: TbankMembershipService,
private readonly config: ConfigService,
private readonly hourlySync: TbankSyncRepository,
) {}
@@ -117,35 +110,14 @@ export class BrokerageOperationsService {
cursor,
};
if (cursor) await this.repository.readPage(readInput);
const screen = planInvestmentScreen(
{
screen: 'operations',
accountId,
from: from.toISOString(),
to: new Date(to.valueOf() - EXCLUSIVE_END_OFFSET_MS).toISOString(),
},
{
accounts: selected,
instruments: [],
membershipKnown: Boolean(connection.membershipAt),
},
now,
);
const intervals = screen.sources.flatMap((source) =>
source.kind === 'operations' &&
source.priority === 'screen' &&
source.accountId &&
source.from &&
source.to
? [{ accountId: source.accountId, from: source.from, to: source.to }]
: [],
const intervals = selected.flatMap((account) =>
from < to ? [{ accountId: account.accountId, from, to }] : [],
);
// The ledger only starts the first-time load of missing intervals; the
// mutable tail belongs to the hourly walk and membership is not rechecked.
if (intervals.length) {
await this.sync.readPlan(userId, intervals, {
movingEnd,
history: true,
revision: connection.revision,
});
}
@@ -406,35 +378,23 @@ export class BrokerageOperationsService {
periods: ranges,
};
let saved = await this.repository.readPage(input);
const schedule = async () => {
const current = await this.access.local(userId, accountId);
if (current.revision !== connection.revision) return;
const snapshot = await this.repository.readPage(input);
if (!snapshot?.connection.membershipAt) return;
const targets = snapshot.connection.accounts.flatMap((account) =>
(snapshot.periods ?? []).map((period) => ({
if (!observe) {
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
(saved?.periods ?? []).map((period) => ({
accountId: account.accountId,
...period,
})),
);
if (targets.length) {
await this.sync.readPlan(userId, targets, {
movingEnd: snapshot.periods?.some((period) => period.movingEnd),
history: true,
movingEnd: saved?.periods?.some((period) => period.movingEnd),
revision: connection.revision,
});
}
};
if (!observe) {
const discovery = await this.membership.request(
userId,
connection.revision,
);
if (discovery) void discovery.done.then(schedule).catch(() => undefined);
else await schedule();
saved = await this.repository.readPage(input);
}
const now = new Date();
const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({
resourceKey: 'accounts/membership',
dataRevision: String(saved?.connection.membershipRevision ?? MISSING_MEMBERSHIP_REVISION),
@@ -451,7 +411,11 @@ export class BrokerageOperationsService {
key: `${period.from}/${period.to}`,
movingEnd: false,
}));
return periods.map((period, index) => {
const rows = saved?.rows ?? [];
const instruments = await this.metadata.savedMany(
rows.map((row) => row.payload as TbankOperationItem),
);
return periods.map((period) => {
const targets = (saved?.connection.accounts ?? []).map((account) => ({
accountId: account.accountId,
...period,
@@ -487,8 +451,7 @@ export class BrokerageOperationsService {
null;
const confirmed = confirmedOperationRanges(plan.intervals, sources);
const meta = investmentResourceMeta({
// eslint-disable-next-line security/detect-object-injection -- The numeric index enumerates the three comparison periods.
resourceKey: `income/${accountId}/${['selected', 'previous', 'yearAgo'][index]}/${period.key}`,
resourceKey: `income/${accountId}/selected/${period.key}`,
dataRevision: saved?.binding ?? 'missing',
availability: !known
? 'missing'
@@ -500,6 +463,7 @@ export class BrokerageOperationsService {
fetchedAt,
freshForMs: plan.intervals.some((range) => range.stale) ? STALE_FRESHNESS_MS : Infinity,
work,
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
dependencies: [membership],
coverage: {
requested: targets.map(serialize),
@@ -542,7 +506,7 @@ export class BrokerageOperationsService {
return {
meta,
items: complete
? (saved?.rows ?? [])
? rows
.filter((row) =>
plan.intervals.some(
(interval) =>
@@ -556,6 +520,10 @@ export class BrokerageOperationsService {
row.payload as TbankOperationItem,
row.occurredAt,
),
logoName:
instruments
.get(metadataKey(row.payload as TbankOperationItem))
?.data?.brand?.logoName?.trim() || null,
...(accountId === 'all'
? {
account: {
@@ -3,10 +3,8 @@ import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
import {
BrokerageOperationDto,
OperationCategory,
} from '../presentation/brokerage-operation.dto';
import { type OperationCategory } from '../../operation-category';
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
const MISSING_TIMESTAMP_COMPONENT = 0;
@@ -5,4 +5,3 @@ export type { OperationInterval } from './domain/operations-sync-plan';
// Public cross-module surface. Nest providers are exported by the matching module.
export { BrokerageOperationDto } from './presentation/brokerage-operation.dto';
export { operationCategories } from './presentation/brokerage-operation.dto';
@@ -16,6 +16,7 @@ import {
import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { operationCategories, type OperationCategory } from '../../operation-category';
const MIN_OPERATIONS_PAGE_SIZE = 1;
@@ -23,16 +24,6 @@ const MAX_CURSOR_LENGTH = 4096;
const MAX_OPERATIONS_PAGE_SIZE = 1000;
export const DEFAULT_OPERATIONS_PAGE_SIZE = 100;
export const operationCategories = [
'trade',
'income',
'tax',
'fee',
'transfer',
'other',
] as const;
export type OperationCategory = (typeof operationCategories)[number];
export class BrokerageOperationsQueryDto {
@ApiPropertyOptional({
format: 'uuid',
@@ -20,6 +20,7 @@ export class ForecastService {
lastAttempt?: InvestmentResourceMetaDto['lastAttempt'];
membershipRevision: number;
portfolio: InvestmentResourceMetaDto;
replacesLatest?: boolean;
revision: string;
scopeKey: string;
sources: {
@@ -74,7 +75,7 @@ export class ForecastService {
.map((dependency) => dependency.sync)
.find((sync) => sync.state === 'failed');
return {
data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null,
data: stored ? filterEvents(stored.payload as T, input.scopeKey, stored.scopeKey) : null,
meta: investmentResourceMeta({
resourceKey: input.scopeKey,
dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION),
@@ -95,7 +96,7 @@ export class ForecastService {
}
}
function filterEvents<T extends object>(payload: T, scopeKey: string): T {
function filterEvents<T extends object>(payload: T, scopeKey: string, savedScopeKey: string): T {
if (!scopeKey.startsWith('events/')) return payload;
try {
const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string];
@@ -111,8 +112,10 @@ function filterEvents<T extends object>(payload: T, scopeKey: string): T {
const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 };
totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value)));
}
const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string];
return {
...value,
window: { from: savedFrom, to: savedTo },
events,
incomeTotalsByCurrency: [...totals]
.sort(([left], [right]) => left.localeCompare(right))
@@ -1,4 +1,4 @@
import { setImmediate as immediate } from 'node:timers/promises';
import assert from 'node:assert/strict';
jest.mock('../../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
@@ -6,9 +6,10 @@ import { PortfolioDto } from '../presentation/portfolio.dto';
import { PortfolioService } from './portfolio.service';
describe('persistent portfolio projection', () => {
const access = { local: jest.fn(), tokenForAttempt: jest.fn() };
const access = { local: jest.fn(), tokenForAttempt: jest.fn(), rejectMembershipToken: jest.fn() };
const tbank = { getPortfolio: jest.fn(), getPositions: jest.fn() };
const metadata = { savedMany: jest.fn(), request: jest.fn() };
const classification = { refresh: jest.fn() };
const repository = {
read: jest.fn(),
reserve: jest.fn(),
@@ -23,6 +24,7 @@ describe('persistent portfolio projection', () => {
access as never,
tbank as never,
metadata as never,
classification as never,
repository as never,
{ request: jest.fn().mockResolvedValue(null) } as never,
{ event: jest.fn() } as never,
@@ -34,6 +36,7 @@ describe('persistent portfolio projection', () => {
accounts: [{ accountId: 'a' }, { accountId: 'b' }],
});
access.tokenForAttempt.mockResolvedValue('synthetic');
access.rejectMembershipToken.mockResolvedValue(undefined);
repository.reserve.mockResolvedValue(null);
repository.read.mockResolvedValue({
membershipAt,
@@ -44,6 +47,37 @@ describe('persistent portfolio projection', () => {
repository.publish.mockResolvedValue(true);
metadata.savedMany.mockResolvedValue(new Map());
metadata.request.mockResolvedValue(null);
classification.refresh.mockResolvedValue(new Map());
});
it('refreshes classification before publishing and passes the hourly walk time', async () => {
const walkStartedAt = new Date('2026-10-31T12:00:00Z');
const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: '1' } };
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
tbank.getPortfolio.mockResolvedValue({ positions: [position] });
tbank.getPositions.mockResolvedValue({ money: [] });
classification.refresh.mockResolvedValue(new Map([[JSON.stringify(['uid', 'figi']), {
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
}]]));
const handle = await service.request('user', 'private', 'a', true, walkStartedAt);
assert(handle);
await handle.done;
expect(classification.refresh).toHaveBeenCalledWith('synthetic', [position], walkStartedAt);
expect(classification.refresh.mock.invocationCallOrder[0]).toBeLessThan(repository.publish.mock.invocationCallOrder[0]);
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.positions[0]).toEqual(expect.objectContaining({
sector: 'Energy', issuer: { id: 'issuer', name: 'Issuer' },
}));
});
it('rejects an invalid classification token without publishing the snapshot', async () => {
repository.reserve.mockResolvedValue({ launch: true, work: { id: 'work', attemptId: 'attempt' } });
tbank.getPortfolio.mockResolvedValue({ positions: [] });
tbank.getPositions.mockResolvedValue({ money: [] });
classification.refresh.mockRejectedValue(new Error('INVALID_TBANK_TOKEN'));
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
expect(repository.publish).not.toHaveBeenCalled();
expect(access.rejectMembershipToken).toHaveBeenCalled();
});
it('normalizes exact signed money, order, UID metadata and unknown types without metadata RPC', async () => {
repository.reserve.mockResolvedValueOnce({
@@ -97,14 +131,9 @@ describe('persistent portfolio projection', () => {
],
work: [],
});
await service.request('user', 'private', 'a');
for (
let index = 0;
index < 10 && !repository.publish.mock.calls.length;
index += 1
) {
await immediate();
}
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (
repository.publish.mock.calls as [unknown, string, PortfolioDto][]
)[0][2];
@@ -147,10 +176,9 @@ describe('persistent portfolio projection', () => {
{ units: '99' },
{ currency: 'EUR', units: '1' },
] });
await service.request('user', 'private', 'a');
for (let index = 0; index < 10 && !repository.publish.mock.calls.length; index++) {
await immediate();
}
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.cashBalances).toEqual([
{ currency: 'RUB', value: '0' },
@@ -165,6 +193,43 @@ describe('persistent portfolio projection', () => {
expect(snapshot.positions[1].averagePurchasePrice).toEqual({ currency: 'RUB', value: '2' });
});
it('counts cash once when T-Bank also returns it as a CURRENCY position', async () => {
repository.reserve.mockResolvedValueOnce({
launch: true,
work: { id: 'work', attemptId: 'attempt' },
});
tbank.getPortfolio.mockResolvedValue({ positions: [
{ figi: 'FUND', instrumentType: 'etf', quantity: { units: '1' }, currentPrice: { currency: 'RUB', units: '120', nano: 240000000 } },
{ figi: 'RUB000UTSTOM', instrumentType: 'currency', quantity: { units: '1', nano: 250000000 }, currentPrice: { currency: 'RUB', units: '1' } },
] });
tbank.getPositions.mockResolvedValue({ money: [{ currency: 'RUB', units: '1', nano: 250000000 }] });
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, portfolio: null }],
work: [],
});
const handle = await service.request('user', 'private', 'a');
assert(handle);
await handle.done;
const snapshot = (repository.publish.mock.calls as [unknown, string, PortfolioDto][])[0][2];
expect(snapshot.totalsByCurrency).toEqual([{ currency: 'RUB', value: '121.49' }]);
expect(snapshot.positions.map((position) => position.type)).toEqual(['FUND', 'CURRENCY']);
expect(snapshot.structure.currencies[0].totalValue).toBe('121.49');
});
// Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response.
it('rejects a stored snapshot payload without the persisted shape', async () => {
repository.read.mockResolvedValue({
membershipAt,
membershipRevision: 1,
accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null,
portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }],
work: [],
});
await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload');
});
it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => {
const snapshot: PortfolioDto = {
lastSuccessfulAt: membershipAt.toISOString(),
@@ -4,6 +4,8 @@ import {
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import {
@@ -20,16 +22,18 @@ import { investmentResourceMeta } from '../../investment-resource.mapper';
import {
hourlyAttempt,
TbankSyncRepository,
} from '../../tbank-sync/tbank-sync.repository';
} from '../../tbank-sync/tbank-sync.api';
import { portfolioStructure } from '../domain/portfolio-structure';
import { sumMoneyByCurrency } from '../domain/portfolio-totals';
import {
portfolioFreshForMs,
PortfolioRepository,
portfolioSnapshot,
portfolioWorkKey,
} from '../infrastructure/portfolio.repository';
import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper';
import { PortfolioDto } from '../presentation/portfolio.dto';
import { type Classification, PositionClassificationService } from './position-classification.service';
const MISSING_DATA_REVISION = 0;
const NO_ACCOUNTS = 0;
@@ -46,6 +50,7 @@ export class PortfolioService {
private readonly access: TbankAccessService,
private readonly tbank: TbankClient,
private readonly metadata: InstrumentMetadataService,
private readonly classification: PositionClassificationService,
private readonly repository: PortfolioRepository,
private readonly membership: TbankMembershipService,
private readonly logger: StructuredLogger,
@@ -86,13 +91,7 @@ export class PortfolioService {
type: account.type,
openedOn: account.openedOn,
snapshot: account.portfolio
? {
...(account.portfolio.payload as unknown as PortfolioDto),
lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(),
isStale:
Date.now() - account.portfolio.fetchedAt.valueOf() >=
portfolioFreshForMs,
}
? savedSnapshot(account.portfolio)
: null,
meta: investmentResourceMeta({
resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`,
@@ -231,8 +230,8 @@ export class PortfolioService {
};
}
request(userId: string, revision: string, accountId: string, force = false) {
return this.queueSnapshot(userId, revision, accountId, force);
request(userId: string, revision: string, accountId: string, force = false, walkStartedAt?: Date) {
return this.queueSnapshot(userId, revision, accountId, force, walkStartedAt);
}
private queueSnapshot(
@@ -240,6 +239,7 @@ export class PortfolioService {
revision: string,
accountId: string,
force = false,
walkStartedAt = new Date(),
) {
const reserved = this.reservations.then(async () => {
const requested = await this.repository.reserve(
@@ -268,9 +268,11 @@ export class PortfolioService {
this.tbank.getPortfolio(token, accountId),
this.tbank.getPositions(token, accountId),
]);
const classifications = await this.classification.refresh(token, portfolio.positions ?? [], walkStartedAt);
const snapshot = await this.normalize(
portfolio.positions ?? [],
positions.money ?? [],
classifications,
);
const published = await this.repository.publish(
attempt,
@@ -309,9 +311,24 @@ export class PortfolioService {
private async normalize(
upstream: NonNullable<TbankPortfolio['positions']>,
money: TbankMoney[],
classifications: ReadonlyMap<string, Classification>,
): Promise<PortfolioDto> {
const lastSuccessfulAt = new Date().toISOString().replace(/\.\d{3}Z$/, 'Z');
const savedMetadata = await this.metadata.savedMany(upstream);
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt);
return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications);
}
}
function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto {
const snapshot = portfolioSnapshot(saved.payload);
return {
...snapshot,
positions: snapshot.positions.map((position) => ({
...position,
sector: position.sector ?? null,
issuer: position.issuer ?? null,
})),
lastSuccessfulAt: saved.fetchedAt.toISOString(),
isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs,
};
}
@@ -0,0 +1,172 @@
import { NotFoundException } from '@nestjs/common';
jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {} }));
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
import { PositionClassificationService } from './position-classification.service';
describe('PositionClassificationService', () => {
const position = { instrumentType: 'share', instrumentUid: 'uid', figi: 'figi', quantity: { units: 1 } };
const saved = { sector: 'Old', sectorFetchedAt: new Date('2026-10-30T12:00:00Z'), assetUid: 'asset', brandUid: 'brand', brandName: 'Old brand', brandFetchedAt: new Date('2026-10-30T12:00:00Z') };
const tbank = { getShare: jest.fn(), getAssetBrand: jest.fn(), getBond: jest.fn(), getEtf: jest.fn() };
const repository = { read: jest.fn(), save: jest.fn() };
const logger = { event: jest.fn() };
const service = new PositionClassificationService(tbank as never, repository as never, logger as never);
beforeEach(() => {
jest.resetAllMocks();
repository.read.mockResolvedValue(saved);
repository.save.mockImplementation((_key: string, update: Record<string, unknown>) => Promise.resolve({ ...saved, ...update }));
tbank.getAssetBrand.mockResolvedValue({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } });
});
it('does not fetch a value at 23:59, and refreshes it at 24:00', async () => {
await service.refresh('token', [position], new Date('2026-10-31T11:59:00Z'));
expect(tbank.getShare).not.toHaveBeenCalled();
tbank.getShare.mockResolvedValue({ uid: 'uid', figi: 'figi', sector: ' Tech ', assetUid: 'asset' });
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(result.values().next().value).toEqual({ sector: 'Tech', issuer: { id: 'new-brand', name: 'New brand' } });
expect(repository.save).toHaveBeenCalledWith(expect.any(String), {
sector: 'Tech', sectorFetchedAt: new Date('2026-10-31T12:00:00Z'),
brandUid: 'new-brand', brandName: 'New brand', brandFetchedAt: new Date('2026-10-31T12:00:00Z'),
});
});
it('accepts a matching FIGI after UID NOT_FOUND', async () => {
tbank.getShare.mockRejectedValueOnce(new NotFoundException()).mockResolvedValueOnce({ uid: 'different', figi: 'figi', sector: 'Energy', assetUid: 'asset' });
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(tbank.getShare).toHaveBeenNthCalledWith(2, 'token', 'figi', InstrumentIdType.Figi, true);
expect(result.values().next().value?.sector).toBe('Energy');
});
it('preserves saved values on mismatched instrument and returns refreshed values on database failure', async () => {
tbank.getShare.mockResolvedValue({ uid: 'wrong', sector: 'Energy' });
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(result.values().next().value?.sector).toBe('Old');
repository.save.mockRejectedValue(new Error('db unavailable'));
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
const fallback = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(fallback.values().next().value?.sector).toBe('Energy');
expect(logger.event).toHaveBeenCalled();
});
it('does not retain an issuer from a previous asset when the asset changes', async () => {
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
tbank.getAssetBrand.mockResolvedValue({ assetUid: 'other', brand: { uid: 'other-brand', name: 'Other brand' } });
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(tbank.getAssetBrand).toHaveBeenCalledWith('token', 'other');
expect(result.values().next().value?.issuer).toEqual({ id: 'other-brand', name: 'Other brand' });
});
it('does not restart the brand clock when the brand fetch fails after an asset change', async () => {
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable'));
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(result.values().next().value?.issuer).toBeNull();
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null,
}));
});
it('does not return the previous asset brand when saving a changed asset fails', async () => {
repository.save.mockRejectedValue(new Error('db unavailable'));
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'other' });
tbank.getAssetBrand.mockRejectedValue(new Error('upstream unavailable'));
const result = await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(result.values().next().value).toEqual({ sector: 'Energy', issuer: null });
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
assetUid: 'other', brandUid: null, brandName: null, brandFetchedAt: null,
}));
expect(logger.event).toHaveBeenCalled();
});
it('records a fetched brand as null when a fresh instrument has no asset_uid', async () => {
repository.read.mockResolvedValue(null);
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy' });
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z'),
}));
});
it('treats a NOT_FOUND answer as fetched and does not refetch it an hour later', async () => {
tbank.getShare.mockRejectedValue(new NotFoundException());
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'),
}));
repository.read.mockResolvedValue({ ...saved, sector: null, sectorFetchedAt: new Date('2026-10-31T12:00:00Z'), assetUid: null, brandUid: null, brandName: null, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
tbank.getShare.mockClear();
await service.refresh('token', [position], new Date('2026-10-31T13:00:00Z'));
expect(tbank.getShare).not.toHaveBeenCalled();
});
it('records a missing brand and restarts its clock when NOT_FOUND has no saved asset UID', async () => {
repository.read.mockResolvedValue(null);
tbank.getShare.mockRejectedValue(new NotFoundException());
const startedAt = new Date('2026-10-31T12:00:00Z');
const result = await service.refresh('token', [position], startedAt);
expect(result.values().next().value).toEqual({ sector: null, issuer: null });
expect(repository.save).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({
brandUid: null, brandName: null, brandFetchedAt: startedAt,
}));
expect(tbank.getAssetBrand).not.toHaveBeenCalled();
});
it('does not leak one refresh call\'s invalid token into another call', async () => {
tbank.getShare.mockRejectedValueOnce(new Error(TbankErrorCode.InvalidToken))
.mockResolvedValueOnce({ uid: 'uid', figi: 'figi', sector: 'Energy', assetUid: 'asset' });
await expect(service.refresh('first', [position], new Date('2026-10-31T12:00:00Z'))).rejects.toThrow();
const result = await service.refresh('second', [position], new Date('2026-10-31T12:00:00Z'));
expect(result.values().next().value?.sector).toBe('Energy');
});
it('propagates an invalid token before publishing a classification', async () => {
tbank.getShare.mockRejectedValue(new Error(TbankErrorCode.InvalidToken));
const result = service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
await expect(result).rejects.toThrow(TbankErrorCode.InvalidToken);
expect(repository.save).not.toHaveBeenCalled();
});
it('leaves a fresh brand untouched when the sector refresh finds the same asset', async () => {
repository.read.mockResolvedValue({ ...saved, brandFetchedAt: new Date('2026-10-31T12:00:00Z') });
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
await service.refresh('token', [position], new Date('2026-10-31T12:00:00Z'));
expect(tbank.getAssetBrand).not.toHaveBeenCalled();
});
it('skips unsupported types, zero-quantity positions, and shares a brand lookup across instruments', async () => {
repository.read.mockResolvedValue(null);
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' });
const result = await service.refresh('token', [
{ instrumentType: 'currency', instrumentUid: 'cash', figi: 'cash', quantity: { units: 1 } },
{ instrumentType: 'share', instrumentUid: 'empty', figi: 'empty', quantity: {} },
{ instrumentType: 'Share', instrumentUid: 'upper', figi: 'upper', quantity: { units: 1 } },
position,
{ instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } },
], new Date('2026-10-31T12:00:00Z'));
expect(result.size).toBe(3);
expect(tbank.getAssetBrand).toHaveBeenCalledTimes(1);
expect(tbank.getEtf).not.toHaveBeenCalled();
});
it('skips duplicate instrument keys and retries a failed asset lookup for another instrument in the same walk', async () => {
repository.read.mockResolvedValue(null);
tbank.getShare.mockResolvedValue({ uid: 'uid', sector: 'Energy', assetUid: 'asset' });
tbank.getBond.mockResolvedValue({ uid: 'bond', sector: 'Energy', assetUid: 'asset' });
tbank.getAssetBrand.mockRejectedValueOnce(new Error('upstream unavailable'))
.mockResolvedValueOnce({ assetUid: 'asset', brand: { uid: 'new-brand', name: 'New brand' } });
const result = await service.refresh('token', [position, position,
{ instrumentType: 'bond', instrumentUid: 'bond', figi: 'bond-figi', quantity: { units: 1 } },
], new Date('2026-10-31T12:00:00Z'));
expect(result.size).toBe(2);
expect(tbank.getShare).toHaveBeenCalledTimes(1);
expect(tbank.getAssetBrand).toHaveBeenCalledTimes(2);
expect([...result.values()].map(({ issuer }) => issuer)).toEqual([
null, { id: 'new-brand', name: 'New brand' },
]);
});
});
@@ -0,0 +1,184 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { isZero } from '../../../../common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
import { metadataKey, TbankClient, type TbankPortfolio } from '../../../../integrations/tbank/tbank.api';
import { PositionClassificationRepository } from '../infrastructure/position-classification.repository';
export type Classification = { issuer: null | { id: string; name: string }; sector: null | string };
type Position = NonNullable<TbankPortfolio['positions']>[number];
type Instrument = { assetUid?: string; figi?: string; sector?: string; uid?: string };
type Row = NonNullable<Awaited<ReturnType<PositionClassificationRepository['read']>>>;
type Update = Parameters<PositionClassificationRepository['save']>[1];
@Injectable()
export class PositionClassificationService {
constructor(
private readonly tbank: TbankClient,
private readonly repository: PositionClassificationRepository,
private readonly logger: StructuredLogger,
) {}
async refresh(token: string, positions: Position[], walkStartedAt: Date): Promise<Map<string, Classification>> {
const result = new Map<string, Classification>();
const inFlight = new Map<string, Promise<unknown>>();
for (const position of positions) {
if (!['bond', 'etf', 'share'].includes((position.instrumentType ?? '').toLowerCase())) continue;
if (isZero(decimal(position.quantity ?? {}))) continue;
const key = metadataKey(position);
if (result.has(key)) continue;
result.set(key, await this.refreshOne(token, position, key, walkStartedAt, inFlight));
}
return result;
}
private async refreshOne(token: string, position: Position, key: string, startedAt: Date,
inFlight: Map<string, Promise<unknown>>): Promise<Classification> {
let saved: null | Row = null;
const update: Update = {};
try {
saved = await this.repository.read(key);
const due = (at: Date | null | undefined) => !at || startedAt.valueOf() - at.valueOf() >= MILLISECONDS_PER_DAY;
const sectorDue = due(saved?.sectorFetchedAt);
const brandDue = due(saved?.brandFetchedAt);
let assetUid = saved?.assetUid ?? null;
let instrumentFresh = false;
if (sectorDue || (brandDue && !saved?.assetUid)) {
const instrument = await this.refreshInstrument(token, position, sectorDue, assetUid, update, startedAt);
assetUid = instrument.assetUid;
instrumentFresh = instrument.fresh;
}
const assetChanged = 'assetUid' in update && update.assetUid !== saved?.assetUid;
if (assetChanged) {
update.brandUid = null;
update.brandName = null;
update.brandFetchedAt = null;
}
if (brandDue || assetChanged) {
await this.refreshBrand(token, assetUid, update, startedAt, inFlight, instrumentFresh);
}
if (Object.keys(update).length) saved = await this.repository.save(key, update);
} catch (error) {
if (this.invalidToken(error)) throw error;
this.failed(error);
}
const current = { ...saved, ...update };
return {
issuer: current.brandUid && current.brandName ? { id: current.brandUid, name: current.brandName } : null,
sector: current.sector ?? null,
};
}
private async refreshInstrument(token: string, position: Position, sectorDue: boolean, savedUid: null | string,
update: Update, startedAt: Date): Promise<{ assetUid: null | string; fresh: boolean }> {
try {
const lookup = await this.instrument(token, position);
if (lookup && !this.matches(lookup)) return { assetUid: savedUid, fresh: false };
if (lookup) return { assetUid: this.acceptInstrument(lookup.found, savedUid, sectorDue, update, startedAt), fresh: true };
if (sectorDue) this.clearSector(update, startedAt);
} catch (error) {
if (this.invalidToken(error)) throw error;
if (error instanceof NotFoundException) {
if (sectorDue) this.clearSector(update, startedAt);
} else this.failed(error);
}
return { assetUid: savedUid, fresh: false };
}
private clearSector(update: Update, startedAt: Date) {
update.sector = null;
update.sectorFetchedAt = startedAt;
}
private acceptInstrument(found: Instrument, savedUid: null | string, sectorDue: boolean, update: Update, startedAt: Date) {
const assetUid = found.assetUid?.trim() || null;
if (assetUid !== savedUid) update.assetUid = assetUid;
if (sectorDue) this.setSector(update, found.sector, startedAt);
return assetUid;
}
private setSector(update: Update, sector: string | undefined, startedAt: Date) {
update.sector = sector?.trim() || null;
update.sectorFetchedAt = startedAt;
}
private async refreshBrand(token: string, assetUid: null | string, update: Update, startedAt: Date,
inFlight: Map<string, Promise<unknown>>, instrumentFresh: boolean) {
if (!assetUid) {
if (instrumentFresh || 'assetUid' in update || 'sectorFetchedAt' in update) this.clearBrand(update, startedAt);
return;
}
try {
const asset = await this.dedupe(inFlight, assetUid, () => this.tbank.getAssetBrand(token, assetUid));
if (asset && asset.assetUid !== assetUid) return;
const uid = asset?.brand?.uid?.trim() || null;
const name = asset?.brand?.name?.trim() || null;
update.brandUid = uid && name ? uid : null;
update.brandName = uid && name ? name : null;
update.brandFetchedAt = startedAt;
} catch (error) {
if (this.invalidToken(error)) throw error;
if (error instanceof NotFoundException) this.clearBrand(update, startedAt);
else this.failed(error);
}
}
private clearBrand(update: Update, startedAt: Date) {
update.brandUid = null;
update.brandName = null;
update.brandFetchedAt = startedAt;
}
private matches(lookup: { found: Instrument; id: string; idType: InstrumentIdType }) {
return (lookup.idType === InstrumentIdType.Uid ? lookup.found.uid : lookup.found.figi) === lookup.id;
}
private invalidToken(error: unknown) {
return error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken);
}
private async instrument(token: string, position: Position) {
const type = (position.instrumentType ?? '').toLowerCase();
const lookup = (id: string, idType: InstrumentIdType) => {
switch (type) {
case 'bond': return this.tbank.getBond(token, id, idType, true);
case 'share': return this.tbank.getShare(token, id, idType, true);
default: return this.tbank.getEtf(token, id, idType, true);
}
};
const uid = position.instrumentUid?.trim();
const figi = position.figi?.trim();
if (uid) {
try {
const found = await lookup(uid, InstrumentIdType.Uid);
if (found) return { found, id: uid, idType: InstrumentIdType.Uid };
} catch (error) {
if (!(error instanceof NotFoundException)) throw error;
}
}
if (figi) {
const found = await lookup(figi, InstrumentIdType.Figi);
return found ? { found, id: figi, idType: InstrumentIdType.Figi } : null;
}
return null;
}
private dedupe<T>(map: Map<string, Promise<unknown>>, key: string, run: () => Promise<T>): Promise<T> {
const pending = map.get(key);
if (pending) return pending as Promise<T>;
const promise = run().catch((error: unknown) => {
map.delete(key);
throw error;
});
map.set(key, promise);
return promise;
}
private failed(error: unknown) {
this.logger.event('warn', 'portfolio.classification.failed', { code: error instanceof Error ? error.name : 'Unknown' });
}
}
@@ -14,12 +14,14 @@ const position = (
isin: null,
ticker: null,
logoName: null,
sector: null,
issuer: null,
absoluteReturn: null,
averagePurchasePrice: null,
});
describe('portfolio structure', () => {
it('includes cash once and keeps currency instruments among other assets', () => {
it('counts cash once and skips CURRENCY positions that mirror it', () => {
const result = portfolioStructure(
[
position('STOCK', '60'),
@@ -30,13 +32,13 @@ describe('portfolio structure', () => {
);
expect(result.currencies[0]).toEqual({
currency: 'RUB',
totalValue: '100',
totalValue: '90',
hasNegativeValues: false,
categories: [
{ type: 'STOCK', value: '60', sharePercent: '60' },
{ type: 'BOND', value: '20', sharePercent: '20' },
{ type: 'OTHER', value: '10', sharePercent: '10' },
{ type: 'CASH', value: '10', sharePercent: '10' },
{ type: 'STOCK', value: '60', sharePercent: '66.666667' },
{ type: 'BOND', value: '20', sharePercent: '22.222222' },
{ type: 'OTHER', value: '0', sharePercent: '0' },
{ type: 'CASH', value: '10', sharePercent: '11.111111' },
],
});
});
@@ -43,6 +43,8 @@ export function portfolioStructure(
currencies.set(money.currency, group);
};
for (const position of positions) {
// CURRENCY positions mirror cash, which is counted once as CASH below.
if (position.type === 'CURRENCY') continue;
const type = ['BOND', 'STOCK'].includes(position.type)
? position.type
: 'OTHER';
@@ -26,6 +26,7 @@ export class ForecastRepository {
membershipRevision: number;
portfolioProjection: string;
portfolios: { accountId: string; dataRevision: number }[];
replacesLatest?: boolean;
revision: string;
scopeKey: string;
sources: ForecastSourceVersion[];
@@ -51,32 +52,30 @@ export class ForecastRepository {
const previous = await tx.tbankForecast.findUnique({
where: { userId_revision_scopeKey: key },
});
// An events read serves the account's latest complete forecast whatever
// the requested window; other scopes keep their exact-key row.
const requestedWindow = eventsWindow(input.scopeKey);
const saved = previous ?? (requestedWindow
? (
await tx.tbankForecast.findMany({
where: {
...owner,
scopeKey: { startsWith: 'events/' },
membershipRevision: input.membershipRevision,
const latest = requestedWindow
? await tx.tbankForecast.findFirst({
where: {
...owner,
// scopeKey is `events/[accountId,from,to]` JSON: pin the account.
scopeKey: {
startsWith: `events/[${JSON.stringify(requestedWindow.accountId)},`,
},
})
).find((forecast) => {
const window = eventsWindow(forecast.scopeKey);
return (
window &&
window.accountId === requestedWindow.accountId &&
window.from <= requestedWindow.to &&
requestedWindow.from <= window.to
);
membershipRevision: input.membershipRevision,
},
orderBy: [{ calculatedAt: 'desc' }, { scopeKey: 'asc' }],
})
: null);
: previous;
const fallback =
saved?.membershipRevision === input.membershipRevision
? saved
: null;
latest?.membershipRevision === input.membershipRevision ? latest : null;
if (!input.candidate) return fallback;
if (!previous && fallback) return fallback;
// Only the default window may replace the latest forecast; an explicit
// range is served from it and publishes only when nothing is saved.
if (!input.replacesLatest && fallback && fallback.scopeKey !== input.scopeKey) {
return fallback;
}
const portfolios = await tx.tbankPortfolio.findMany({
where: {
...owner,
@@ -152,7 +151,7 @@ export class ForecastRepository {
) as Prisma.InputJsonObject;
const timestamps = input.timestamps as Prisma.InputJsonArray;
if (
fallback &&
fallback?.scopeKey === input.scopeKey &&
isDeepStrictEqual(fallback.dependencyRevisions, revisions) &&
isDeepStrictEqual(fallback.sourceTimestamps, timestamps)
) {
@@ -200,3 +200,16 @@ export class PortfolioRepository {
export function portfolioWorkKey(accountId: string): string {
return `portfolio:${JSON.stringify(accountId)}`;
}
// Json payload is written only by publish(); a shape check turns corruption into an explicit 500.
export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto {
const snapshot = value as null | Partial<PortfolioDto>;
if (
!Array.isArray(snapshot?.positions) ||
!Array.isArray(snapshot.totalsByCurrency) ||
!Array.isArray(snapshot.cashBalances)
) {
throw new Error('Corrupted portfolio snapshot payload');
}
return snapshot as PortfolioDto;
}
@@ -0,0 +1,29 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../prisma/prisma.service';
@Injectable()
export class PositionClassificationRepository {
constructor(private readonly prisma: PrismaService) {}
read(key: string) {
return this.prisma.tbankInstrumentClassification.findUnique({
where: { provider_instrumentKey: { provider: 'tbank', instrumentKey: key } },
});
}
save(key: string, data: {
assetUid?: null | string;
brandFetchedAt?: Date | null;
brandName?: null | string;
brandUid?: null | string;
sector?: null | string;
sectorFetchedAt?: Date;
}) {
return this.prisma.tbankInstrumentClassification.upsert({
where: { provider_instrumentKey: { provider: 'tbank', instrumentKey: key } },
create: { provider: 'tbank', instrumentKey: key, ...data },
update: data,
});
}
}
@@ -1,4 +1,5 @@
import type { Money } from '../../../../common/money.types';
import type { Classification } from '../application/position-classification.service';
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
import {
@@ -35,9 +36,10 @@ function mapPortfolio(
cash: TbankMoney[],
metadata: ReadonlyMap<string, { data?: null | TbankInstrument }>,
lastSuccessfulAt: string,
classification: ReadonlyMap<string, Classification> = new Map(),
): PortfolioDto {
const positions = upstream
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null))
.map((position) => mapPosition(position, metadata.get(metadataKey(position))?.data ?? null, classification.get(metadataKey(position))))
.filter((position): position is PortfolioPositionDto => position !== null);
const balances = new Map<string, ReturnType<typeof decimal>>();
for (const value of cash) {
@@ -63,6 +65,7 @@ function mapPortfolio(
function mapPosition(
position: NonNullable<TbankPortfolio['positions']>[number],
metadata: null | TbankInstrument,
classification?: Classification,
): null | PortfolioPositionDto {
const quantity = decimal(position.quantity ?? {});
if (isZero(quantity)) return null;
@@ -96,6 +99,8 @@ function mapPosition(
isin: metadata?.isin?.trim() || null,
ticker: metadata?.ticker?.trim() || null,
logoName: metadata?.brand?.logoName?.trim() || null,
sector: classification?.sector ?? null,
issuer: classification?.issuer ?? null,
};
}
@@ -126,7 +131,8 @@ function totalsByCurrency(
for (const value of money) addTotal(value.currency, decimal(value));
for (const position of positions) {
const price = position.currentPrice;
if (!price) continue;
// CURRENCY positions mirror the cash balances already added above.
if (!price || types[position.instrumentType?.toLowerCase() ?? ''] === 'CURRENCY') continue;
addTotal(
price.currency,
multiply(decimal(position.quantity ?? {}), decimal(price)),
@@ -5,4 +5,5 @@ export { PortfolioService } from './application/portfolio.service';
export {
PortfolioDto,
PortfolioPositionDto,
PortfolioStructureDto,
} from './presentation/portfolio.dto';
@@ -5,8 +5,10 @@ import { AuthModule } from '../../auth/auth.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { ForecastService } from './application/forecast.service';
import { PortfolioService } from './application/portfolio.service';
import { PositionClassificationService } from './application/position-classification.service';
import { ForecastRepository } from './infrastructure/forecast.repository';
import { PortfolioRepository } from './infrastructure/portfolio.repository';
import { PositionClassificationRepository } from './infrastructure/position-classification.repository';
import { PortfolioController } from './presentation/portfolio.controller';
@Module({
@@ -15,6 +17,8 @@ import { PortfolioController } from './presentation/portfolio.controller';
providers: [
PortfolioService,
PortfolioRepository,
PositionClassificationService,
PositionClassificationRepository,
ForecastService,
ForecastRepository,
],
@@ -4,6 +4,14 @@ import { MoneyDto } from '../../../../common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
export class PortfolioIssuerDto {
@ApiProperty()
declare id: string;
@ApiProperty()
declare name: string;
}
export class PortfolioPositionDto {
@ApiPropertyOptional({ type: String, nullable: true })
instrumentUid?: null | string;
@@ -52,6 +60,12 @@ export class PortfolioPositionDto {
@ApiProperty({ type: String, nullable: true })
declare logoName: null | string;
@ApiProperty({ type: String, nullable: true })
declare sector: null | string;
@ApiProperty({ type: PortfolioIssuerDto, nullable: true })
declare issuer: null | PortfolioIssuerDto;
}
export class PortfolioStructureCategoryDto {
@@ -132,6 +132,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
// upstream call inside one. The attempt outcome is derived from durable
// step rows by the repository, never from walk-local state.
private async walk(attempt: TbankSyncAttempt) {
const walkStartedAt = new Date();
this.active.add(attempt.attemptId);
try {
const { userId, revision } = attempt;
@@ -155,6 +156,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
accounts,
dispatch,
dispatchProblems,
walkStartedAt,
);
await this.waitWorks(userId, children.map((child) => child.id));
for (const account of accounts) {
@@ -239,6 +241,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
accounts: Accounts,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
walkStartedAt: Date,
) {
for (const account of accounts) {
await this.dispatchPortfolio(
@@ -247,6 +250,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
account.accountId,
dispatch,
dispatchProblems,
walkStartedAt,
);
}
for (const account of accounts) {
@@ -264,6 +268,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
account.accountId,
dispatch,
dispatchProblems,
walkStartedAt,
);
}
}
@@ -274,10 +279,11 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
walkStartedAt: Date,
) {
try {
const work = await this.withBound(
this.portfolio.request(userId, revision, accountId, true),
this.portfolio.request(userId, revision, accountId, true, walkStartedAt),
);
if (work && work !== BOUND_EXPIRED) {
await dispatch({ accountId, id: work.id, section: 'portfolio' });
@@ -335,9 +341,12 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit {
accountId: string,
dispatch: DispatchStep,
dispatchProblems: HourlySection[],
walkStartedAt: Date,
) {
try {
const ids = await this.withBound(this.events.refresh(userId, accountId));
const ids = await this.withBound(
this.events.refresh(userId, accountId, {}, walkStartedAt),
);
if (ids !== BOUND_EXPIRED) {
for (const id of ids) {
await dispatch({ accountId, id, section: 'events' });
@@ -0,0 +1 @@
export { hourlyAttempt, TbankSyncRepository } from './tbank-sync.repository';
@@ -23,7 +23,7 @@ import {
import { MAX_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
import { InvestmentAttemptProblemDto } from '../investment-resource.dto';
import { operationCategories } from '../operations/operations.api';
import { operationCategories } from '../operation-category';
const MIN_ACCOUNT_ID_LENGTH = 1;
const MIN_SYNC_PAGE_SIZE = 1;
@@ -209,13 +209,6 @@ export const investmentScreenSchema = {
},
};
export class TbankSyncAcceptedDto {
@ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema })
declare scope: InvestmentScreenDto;
@ApiProperty({ format: 'date-time' }) declare now: string;
}
export class TbankSyncStatusDto {
@ApiProperty({ format: 'uuid' }) declare id: string;
@ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true })
@@ -229,26 +222,6 @@ export class TbankSyncStatusDto {
null | string;
}
export class HourlySyncSectionDto {
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@ApiPropertyOptional({
type: String,
nullable: false,
description: 'Account identifier of the problematic section, when scoped.',
})
declare accountId?: string;
@ApiProperty({
type: String,
nullable: true,
description:
'Safe error code of the failed section. No tokens or upstream payloads.',
})
declare errorCode: null | string;
}
export class HourlySyncStatusDto {
@ApiProperty({
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
@@ -0,0 +1,14 @@
jest.mock('../../../prisma/prisma.service', () => ({
PrismaService: class {},
}));
import { screenIntent } from './tbank-sync.repository';
describe('screen sync intent', () => {
it('restores a persisted intent and rejects a corrupted one', () => {
const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } };
expect(screenIntent(intent)).toEqual(intent);
expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent');
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
});
});
@@ -8,6 +8,15 @@ import type { InvestmentScreenDto } from './tbank-sync.dto';
import { PrismaService } from '../../../prisma/prisma.service';
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
export function screenIntent(value: Prisma.JsonValue): ScreenIntent {
const intent = value as null | Partial<ScreenIntent>;
if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') {
throw new Error('Corrupted screen sync intent');
}
return intent as ScreenIntent;
}
export type ScreenProgress = {
attempts?: Record<string, string>;
children: string[];
@@ -43,16 +52,17 @@ type HourlyAttempt = {
export function hourlyAttempt(
hourly: HourlyAttempt | null,
section: HourlySection['section'],
section: HourlySection['section'] | readonly HourlySection['section'][],
accountId?: string,
): InvestmentAttemptDto {
if (!hourly) {
return { state: 'none', startedAt: null, finishedAt: null, problems: [] };
}
const sections = Array.isArray(section) ? section : [section];
const problems = hourly.sections
.filter(
(entry) =>
entry.section === section &&
sections.includes(entry.section) &&
(accountId === undefined || entry.accountId === accountId),
)
.map((entry) => ({
@@ -182,7 +192,8 @@ export class TbankSyncRepository {
// The timer only proposes work; this reservation is the durable fence. The
// attempt start (createdAt) is the basis of the next hourly term, including
// failed and interrupted attempts.
// failed and interrupted attempts. After downtime the next tick is due at
// once, so no catch-up queue forms.
reserveHourly(userId: string, runId: string, hourMs: number) {
return this.prisma.$transaction(async (tx) => {
const connection = await tx.tbankConnection.findFirst({
@@ -200,7 +211,16 @@ export class TbankSyncRepository {
});
const now = new Date();
if (previous) {
const due = now.valueOf() - previous.createdAt.valueOf() >= hourMs;
// Terms are hourMs steps from the attempt start; a walk still running
// at a term consumes it. A dead process's attempt has no known end, so
// it consumes only its own term.
const started = previous.createdAt.valueOf();
const ended =
previous.state === 'running' ? started : previous.updatedAt.valueOf();
const dueAt =
started +
Math.max(hourMs, Math.ceil((ended - started) / hourMs) * hourMs);
const due = now.valueOf() >= dueAt;
if (previous.state === 'running') {
if (previous.processRunId === runId) return null;
// A dead process cannot finish its attempt; close it from its
@@ -215,10 +235,8 @@ export class TbankSyncRepository {
processRunId: runId,
state: 'running',
priority: 0,
intent: JSON.parse(
JSON.stringify({ source: 'hourly' }),
) as Prisma.InputJsonObject,
progress: { sections: [] } as unknown as Prisma.InputJsonValue,
intent: { source: 'hourly' },
progress: { sections: [] },
errorCode: null,
};
const work = await tx.tbankSyncWork.upsert({
@@ -2,314 +2,24 @@ import {
ConflictException,
Injectable,
NotFoundException,
ServiceUnavailableException,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
import {
InstrumentMetadataService,
TbankAccessService,
TbankMembershipService,
type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api';
import {
CandleService,
CatalogDiscoveryService,
CatalogEnrichmentService,
} from '../instruments/instruments.api';
import { planInvestmentScreen } from '../investment-sync-plan';
import {
BrokerageOperationsSyncService,
type OperationInterval,
} from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api';
import { normalizeScreen } from './tbank-screen';
import { InvestmentScreenDto, TbankSyncStatusDto } from './tbank-sync.dto';
import {
type ScreenIntent,
TbankSyncRepository,
} from './tbank-sync.repository';
const FOREGROUND_CANDLE_PRIORITY = 0;
import { TbankAccessService } from '../../../integrations/tbank/tbank.api';
import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { TbankSyncStatusDto } from './tbank-sync.dto';
import { screenIntent, TbankSyncRepository } from './tbank-sync.repository';
@Injectable()
export class TbankSyncService {
private readonly runId = randomUUID();
private readonly startedAt = new Date();
private reservations: Promise<unknown> = Promise.resolve();
constructor(
private readonly repository: TbankSyncRepository,
private readonly access: TbankAccessService,
private readonly membership: TbankMembershipService,
private readonly portfolio: PortfolioService,
private readonly operations: BrokerageOperationsSyncService,
private readonly metadata: InstrumentMetadataService,
private readonly discovery: CatalogDiscoveryService,
private readonly sources: CatalogEnrichmentService,
private readonly candles: CandleService,
) {}
async request(userId: string, input: InvestmentScreenDto) {
const scope = normalizeScreen(input);
const connection = await this.access.local(userId, scope.accountId);
const reservation = this.reservations.then(() =>
this.repository.reserve(userId, connection.revision, scope, this.runId),
);
this.reservations = reservation.catch(() => undefined);
const reserved = await reservation;
if (!reserved) throw new ServiceUnavailableException();
const intent = reserved.work.intent as unknown as ScreenIntent;
if (reserved.launch) {
const attempt = {
userId,
revision: connection.revision,
id: reserved.work.id,
attemptId: reserved.work.attemptId,
};
setImmediate(() => {
void this.dispatch(attempt, intent);
});
}
return { id: reserved.work.id, ...intent };
}
private async dispatch(attempt: TbankSyncAttempt, intent: ScreenIntent) {
const children: string[] = [];
const add = async (ids: (null | string | undefined)[]) => {
children.push(...ids.filter((id): id is string => Boolean(id)));
await this.repository.progress(attempt, children, false);
};
try {
const { userId, revision } = attempt;
const { scope } = intent;
const now = new Date(intent.now);
const member = await this.membership.request(userId, revision, true);
await add([member?.id]);
await member?.done;
const current = await this.access.local(userId, scope.accountId);
if (current.revision !== revision) return;
const accountScope = scope.accountId ?? 'all';
const selectedAccounts = current.accounts.filter(
(account) =>
accountScope === 'all' || account.accountId === accountScope,
);
if (
['account', 'accounts', 'bond', 'events', 'stock'].includes(
scope.screen,
)
) {
for (const account of scope.screen === 'stock' ||
scope.screen === 'bond'
? current.accounts
: selectedAccounts) {
const work = await this.portfolio.request(
userId,
revision,
account.accountId,
true,
);
await add([work?.id]);
await work?.done;
}
}
const saved = await this.portfolio.read(
userId,
scope.screen === 'stock' || scope.screen === 'bond'
? 'all'
: accountScope,
);
const positions = saved.data?.positions ?? [];
const instruments = [
...new Map(
positions.flatMap((position) =>
position.instrumentUid &&
(position.type === 'STOCK' || position.type === 'BOND')
? [
[
position.instrumentUid,
{
uid: position.instrumentUid,
type: position.type,
historyFrom: null,
},
] as const,
]
: [],
),
).values(),
];
const plan = planInvestmentScreen(
{
...scope,
candleRange: scope.candleRange
? {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
}
: undefined,
},
{
accounts: selectedAccounts,
instruments:
scope.screen === 'stocks' || scope.screen === 'bonds'
? []
: instruments,
membershipKnown: Boolean(current.membershipAt),
},
now,
true,
);
const intervals: OperationInterval[] = plan.sources.flatMap((source) =>
source.kind === 'operations' &&
source.accountId &&
source.from &&
source.to
? [
{
accountId: source.accountId,
from: source.from,
to: source.to,
...(source.instrumentUid
? { instrumentUid: source.instrumentUid }
: {}),
},
]
: [],
);
if (intervals.length) {
await add([
await this.operations.request(
userId,
intervals,
true,
'screen',
revision,
),
]);
}
for (const source of plan.sources) {
const uid = source.instrumentUid;
const type = source.instrumentType === 'BOND' ? 'bond' : 'share';
if (source.kind === 'catalog') {
await add(
await this.discovery.refresh(userId, revision, {
instrumentType: type,
query: source.query ?? '',
}),
);
}
if (!uid) continue;
if (source.kind === 'metadata') {
await add([
await this.metadata.request(userId, revision, {
instrumentUid: uid,
}),
]);
}
if (source.kind === 'quote') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'bond' ? 'bond-current' : 'detail',
'last-price',
'trading-status',
'comparison',
],
),
);
}
if (source.kind === 'company') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['description', 'fundamentals'],
),
);
}
if (source.kind === 'metrics') {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
['coupons', 'yield', 'rating'],
),
);
}
if (source.kind === 'calendar' && source.from && source.to) {
await add(
await this.sources.requestMany(
userId,
revision,
type,
[uid],
true,
[
type === 'share'
? 'dividends'
: scope.screen === 'bond'
? 'coupons'
: 'bond-events',
],
{
from: source.from.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
to: source.to.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH),
ticker:
positions.find((position) => position.instrumentUid === uid)
?.ticker ?? undefined,
},
),
);
}
}
if (
(scope.screen === 'stock' || scope.screen === 'bond') &&
scope.instrumentUid
) {
await add([
await this.candles.request(
userId,
revision,
{
uid: scope.instrumentUid,
instrumentType: scope.screen === 'bond' ? 'bond' : 'share',
period: scope.candlePeriod ?? 'month',
...(scope.candleRange
? {
range: {
from: new Date(scope.candleRange.from),
to: new Date(scope.candleRange.to),
},
}
: {}),
},
now,
true,
FOREGROUND_CANDLE_PRIORITY,
),
]);
}
await this.repository.progress(attempt, children, true);
} catch {
await this.repository
.progress(attempt, children, true, TbankErrorCode.Unavailable)
.catch(() => undefined);
}
}
async status(userId: string, id: string): Promise<TbankSyncStatusDto> {
const connection = await this.access
.local(userId)
@@ -322,7 +32,7 @@ export class TbankSyncService {
if (!saved.work.scopeKey.startsWith('screen:')) {
return this.sourceStatus(userId, saved.work);
}
const intent = saved.work.intent as unknown as ScreenIntent;
const intent = screenIntent(saved.work.intent);
await this.access.local(userId, intent.scope.accountId);
const children = await Promise.all(
saved.children.map((child) =>
@@ -65,6 +65,14 @@ export class UpcomingEventDto {
declare projectedAmount: MoneyDto | null;
}
export class UpcomingEventsWindowDto {
@ApiProperty({ format: 'date' })
declare from: string;
@ApiProperty({ format: 'date' })
declare to: string;
}
export class UpcomingEventsDto {
@ApiProperty()
declare accountId: string;
@@ -72,6 +80,13 @@ export class UpcomingEventsDto {
@ApiProperty({ format: 'date-time' })
declare calculatedAt: string;
@ApiProperty({
type: UpcomingEventsWindowDto,
description:
'Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.',
})
declare window: UpcomingEventsWindowDto;
@ApiProperty({ type: UpcomingEventDto, isArray: true })
declare events: UpcomingEventDto[];
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
import {
add,
type Decimal,
decimalFromString,
formatDecimal,
isZero,
@@ -23,7 +24,7 @@ import {
import {
hourlyAttempt,
TbankSyncRepository,
} from '../tbank-sync/tbank-sync.repository';
} from '../tbank-sync/tbank-sync.api';
import { bondEvent, dividendEvent } from './upcoming-event.mapper';
import {
UpcomingEventDto,
@@ -31,7 +32,7 @@ import {
UpcomingEventsQueryDto,
} from './upcoming-events.dto';
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
export enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
const FIRST_BATCH_OFFSET = 0;
@@ -149,7 +150,7 @@ export class UpcomingEventsService {
a.type.localeCompare(b.type) ||
a.ticker.localeCompare(b.ticker),
);
const candidate: undefined | UpcomingEventsDto = ready
const candidate: Omit<UpcomingEventsDto, 'window'> | undefined = ready
? {
accountId,
calculatedAt: new Date().toISOString(),
@@ -162,6 +163,7 @@ export class UpcomingEventsService {
revision: connection.revision,
membershipRevision: connection.membershipRevision,
scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`,
replacesLatest: query.from === undefined && query.to === undefined,
portfolio: portfolio.meta,
lastAttempt: hourlyAttempt(
hourly,
@@ -183,6 +185,7 @@ export class UpcomingEventsService {
userId: string,
accountId: string,
query: UpcomingEventsQueryDto = {},
walkStartedAt?: Date,
): Promise<string[]> {
const window = dateWindow(query);
await this.access.local(userId, accountId);
@@ -193,6 +196,7 @@ export class UpcomingEventsService {
positions,
window,
false,
walkStartedAt,
);
return launched;
}
@@ -202,6 +206,7 @@ export class UpcomingEventsService {
positions: Position[],
window: { from: string; to: string },
observe: boolean,
refreshBefore?: Date,
) {
const metadata = await this.metadata.savedMany(positions);
const sources: Awaited<ReturnType<CatalogEnrichmentService['readMany']>> =
@@ -227,6 +232,7 @@ export class UpcomingEventsService {
identities.map((entry) => [entry.uid, entry.ticker]),
),
},
refreshBefore,
);
sources.push(...batch);
for (const source of batch) {
@@ -280,7 +286,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
}
function incomeTotals(events: UpcomingEventDto[]): MoneyDto[] {
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
const totals = new Map<string, Decimal>();
for (const event of events) {
if (!['coupon', 'dividend'].includes(event.type) || !event.projectedAmount) {
continue;
+41 -1
View File
@@ -1,11 +1,51 @@
import { INestApplication } from '@nestjs/common';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import { HttpErrorCode } from './common/error-code';
import { MoexErrorCode } from './integrations/moex/moex.client';
import { NcrErrorCode } from './integrations/ncr/ncr-ratings.client';
import { TbankErrorCode } from './integrations/tbank/tbank-error-code';
import { AuthErrorCode } from './modules/auth/application/auth-error-code';
import { BankErrorCode } from './modules/banking/bank-cards/application/bank-card-input';
import { IncomeErrorCode } from './modules/investments/income/income-period';
import { InstrumentErrorCode } from './modules/investments/instruments/domain/instrument-error-code';
import { OperationErrorCode } from './modules/investments/operations/domain/operation-error-code';
import { EventsErrorCode } from './modules/investments/upcoming-events/upcoming-events.service';
export const API_PREFIX = 'api/v1';
// Nest Swagger cannot express additionalProperties on a class, so closed request
// bodies are restored here after generation; the runtime allowlist stays in the controller.
const CLOSED_SCHEMAS = ['StockScreenerCriteriaDto'];
// Every code HttpExceptionFilter can emit. Assembled at the composition root so
// common/ProblemDetailsDto does not depend on modules.
export const PROBLEM_CODES = [
...Object.values(HttpErrorCode),
...Object.values(AuthErrorCode),
...Object.values(OperationErrorCode),
...Object.values(InstrumentErrorCode),
...Object.values(TbankErrorCode),
...Object.values(BankErrorCode),
...Object.values(MoexErrorCode),
...Object.values(NcrErrorCode),
...Object.values(EventsErrorCode),
...Object.values(IncomeErrorCode),
];
export function createOpenApiDocument(app: INestApplication) {
return SwaggerModule.createDocument(
const document = SwaggerModule.createDocument(
app,
new DocumentBuilder().setTitle('WealthLedger API').setVersion('v1').build(),
);
const schemas = document.components?.schemas ?? {};
for (const [name, schema] of Object.entries(schemas)) {
if (CLOSED_SCHEMAS.includes(name) && 'properties' in schema) {
schema.additionalProperties = false;
}
}
const problem = schemas.ProblemDetailsDto;
const problemCode = problem && 'properties' in problem ? problem.properties?.code : undefined;
if (problemCode && !('$ref' in problemCode)) problemCode.enum = PROBLEM_CODES;
return document;
}
+3
View File
@@ -9,6 +9,7 @@ import { AppModule } from '../src/app.module';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
describe('All accounts HTTP boundary', () => {
@@ -31,6 +32,8 @@ describe('All accounts HTTP boundary', () => {
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication();
configureApp(app);
+245 -14
View File
@@ -2,7 +2,9 @@ import { INestApplication } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { JwtService } from '@nestjs/jwt';
import { Test, TestingModule } from '@nestjs/testing';
import * as bcrypt from 'bcrypt';
import { PARAMS_PROVIDER_TOKEN } from 'nestjs-pino';
import { createHash, randomUUID } from 'node:crypto';
import request from 'supertest';
import { App } from 'supertest/types';
@@ -10,6 +12,7 @@ import { AppModule } from '../src/app.module';
import { loggingOptions } from '../src/common/logging/logging.module';
import { configureApp } from '../src/main';
import { AuthService } from '../src/modules/auth/application/auth.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
type Authentication = { accessToken: string; user: { email: string } };
@@ -29,11 +32,15 @@ describe('Authentication (e2e)', () => {
pinoHttp: {
...loggingOptions(config),
level: 'info',
transport: undefined,
stream: { write: (line: string) => logs.push(JSON.parse(line) as Record<string, unknown>) },
},
assignResponse: true,
}),
}).compile();
})
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = moduleFixture.createNestApplication();
configureApp(app);
@@ -90,6 +97,171 @@ describe('Authentication (e2e)', () => {
await agent.post('/api/v1/auth/logout').expect(204);
});
it('rejects a valid refresh token as Bearer while its session stays usable', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'bearer@example.test', password: 'a secure password' })
.expect(200);
const cookie = registration.headers['set-cookie'][0];
const refreshToken = /^refresh_token=([^;]+)/.exec(cookie)?.[1];
if (!refreshToken) throw new Error('Expected a refresh token cookie');
// Signed, unexpired and backed by a live session: only its type is wrong.
await expect(app.get(JwtService).verifyAsync(refreshToken)).resolves.toMatchObject({
type: 'refresh',
});
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${refreshToken}`)
.expect(401)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual({ status: 401, code: 'UNAUTHORIZED' }),
);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(200);
});
describe('refresh cookie policy', () => {
const attributes = (cookie: string) =>
cookie
.split('; ')
.slice(1)
.filter((attribute) => !attribute.startsWith('Expires='))
.sort((a, b) => a.localeCompare(b));
const host = ['HttpOnly', 'Path=/api/v1/auth', 'SameSite=Lax'];
// Persistent for the Session lifetime (7 days), otherwise the browser drops it on restart.
const persistent = [...host, 'Max-Age=604800'];
const sorted = (items: string[]) => [...items].sort((a, b) => a.localeCompare(b));
const login = async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'cookie@example.test', password: 'a secure password' })
.expect(200);
return registration.headers['set-cookie'][0];
};
// The cookie policy reads NODE_ENV per request; the e2e runner defaults to development.
const inEnvironment = async <T>(nodeEnv: string, run: () => Promise<T>) => {
const previous = process.env['NODE_ENV'];
process.env['NODE_ENV'] = nodeEnv;
try {
return await run();
} finally {
process.env['NODE_ENV'] = previous;
}
};
it('issues and rotates it persistent (Max-Age) HttpOnly, Lax, path-scoped, host-only and Secure in production, and clears it', async () => {
await inEnvironment('production', async () => {
const issued = await login();
const rotated = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', issued)
.expect(200);
const cleared = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
.set('Cookie', rotated.headers['set-cookie'][0])
.expect(204);
// Exact set: any added Domain, changed Max-Age or dropped flag fails.
for (const cookie of [issued, rotated.headers['set-cookie'][0]]) {
expect(attributes(cookie)).toEqual(sorted([...persistent, 'Secure']));
}
// Clearing must not carry Max-Age; Express expires it with a past Expires.
const clearedCookie = cleared.headers['set-cookie'][0];
expect(attributes(clearedCookie)).toEqual(sorted([...host, 'Secure']));
expect(clearedCookie).toContain('Expires=Thu, 01 Jan 1970');
});
});
it('drops only Secure in development and keeps every other attribute', async () => {
await inEnvironment('development', async () => {
expect(attributes(await login())).toEqual(sorted(persistent));
});
});
});
describe('expired credentials', () => {
const sha256 = (token: string) => createHash('sha256').update(token).digest('hex');
const PAST = () => Math.floor(Date.now() / 1000) - 60;
const FUTURE = () => Math.floor(Date.now() / 1000) + 600;
const register = async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'expiry@example.test', password: 'a secure password' })
.expect(200);
const session = await app.get(PrismaService).session.findFirstOrThrow();
return {
cookie: registration.headers['set-cookie'][0],
sub: session.userId,
sid: session.id,
};
};
it('rejects a signed expired access token but admits the same claims unexpired', async () => {
const { sub, sid } = await register();
const jwt = app.get(JwtService);
const expired = await jwt.signAsync({ sub, sid, type: 'access', exp: PAST() });
const fresh = await jwt.signAsync({ sub, sid, type: 'access', exp: FUTURE() });
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${expired}`)
.expect(401);
await request(app.getHttpServer())
.get('/api/v1/auth/me')
.set('Authorization', `Bearer ${fresh}`)
.expect(200)
.expect(({ body }: { body: unknown }) =>
expect((body as { email: string }).email).toBe('expiry@example.test'),
);
});
it('rejects a signed expired refresh token that matches the live session hash', async () => {
const { sub, sid } = await register();
const jwt = app.get(JwtService);
const prisma = app.get(PrismaService);
const claims = { sub, sid, type: 'refresh' };
const expired = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: PAST() });
const fresh = await jwt.signAsync({ ...claims, jti: randomUUID(), exp: FUTURE() });
// Hash, session and signature all valid: only `exp` differs between the two.
await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(expired) } });
const rejected = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', `refresh_token=${expired}`)
.expect(401);
expect(rejected.headers['set-cookie']).toBeUndefined();
await prisma.session.update({ where: { id: sid }, data: { refreshTokenHash: sha256(fresh) } });
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', `refresh_token=${fresh}`)
.expect(200);
});
it('rejects a valid refresh token once its session is DB-expired', async () => {
const { cookie, sid } = await register();
const prisma = app.get(PrismaService);
await prisma.session.update({ where: { id: sid }, data: { expiresAt: new Date(Date.now() - 1000) } });
const rejected = await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(401);
expect(rejected.headers['set-cookie']).toBeUndefined();
expect(await prisma.session.count()).toBe(0);
await request(app.getHttpServer())
.post('/api/v1/auth/refresh')
.set('Cookie', cookie)
.expect(401);
});
});
it('keeps logout idempotent for an invalid refresh token', async () => {
const logout = await request(app.getHttpServer())
.post('/api/v1/auth/logout')
@@ -261,6 +433,45 @@ describe('Authentication (e2e)', () => {
expect(await app.get(PrismaService).user.count()).toBe(0);
});
it('applies the ASCII 8..72 password policy on register and keeps login lenient', async () => {
const validation = (detail: string) => ({
status: 400,
code: 'VALIDATION_ERROR',
errors: [{ pointer: '#/password', detail }],
});
for (const [index, password] of ['a'.repeat(8), ' '.repeat(2) + 'b'.repeat(70)].entries()) {
const credentials = { email: `user${index}@example.com`, password };
await request(app.getHttpServer()).post('/api/v1/auth/register').send(credentials).expect(200);
await request(app.getHttpServer()).post('/api/v1/auth/login').send(credentials).expect(200);
}
for (const password of ['a'.repeat(7), 'a'.repeat(73), 'пароль-пароль', 'passw😀rd', 'pass\tword1']) {
await request(app.getHttpServer())
.post('/api/v1/auth/register')
.send({ email: 'rejected@example.com', password })
.expect(400)
.expect('Content-Type', /application\/problem\+json/)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual(validation('must be 8-72 printable ASCII characters')),
);
}
expect(await app.get(PrismaService).user.count()).toBe(2);
// An account created under the previous policy (Cyrillic, 12+ chars) must still log in.
const legacy = { email: 'legacy@example.com', password: 'пароль-пароль' };
const prisma = app.get(PrismaService);
await prisma.user.create({
data: { id: randomUUID(), email: legacy.email, passwordHash: await bcrypt.hash(legacy.password, 4) },
});
await request(app.getHttpServer()).post('/api/v1/auth/login').send(legacy).expect(200);
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.send({ email: legacy.email, password: 'a'.repeat(73) })
.expect(400)
.expect(({ body }: { body: unknown }) =>
expect(body).toEqual(validation('must not exceed 72 UTF-8 bytes')),
);
});
it('guards T-Bank connection and never returns a token', async () => {
const registration = await request(app.getHttpServer())
.post('/api/v1/auth/register')
@@ -329,7 +540,14 @@ describe('Authentication (e2e)', () => {
'X-Request-ID',
);
expect(logs.filter((entry) => entry.requestId === response.headers['x-request-id'])).toEqual([
expect.objectContaining({ level: 40, message: 'http.request.completed', status: 401, code: 'UNAUTHORIZED' }),
expect.objectContaining({
level: 40,
message: 'http.request.completed',
method: 'GET',
route: '/api/v1/auth/me',
status: 401,
code: 'UNAUTHORIZED',
}),
]);
});
@@ -341,7 +559,7 @@ describe('Authentication (e2e)', () => {
new Error('operation failed\npassword: SYNTHETIC_AUDIT_SECRET'),
);
const response = await request(app.getHttpServer())
await request(app.getHttpServer())
.post('/api/v1/auth/login')
.set('X-Request-ID', clientRequestId)
.send({ email: 'user@example.com', password: 'a secure password' })
@@ -351,17 +569,30 @@ describe('Authentication (e2e)', () => {
expect(body).toEqual({ status: 500, code: 'INTERNAL_SERVER_ERROR' }),
);
const completed = logs.filter((entry) => entry.requestId === response.headers['x-request-id']);
expect(completed).toEqual([
expect.objectContaining({
requestId: response.headers['x-request-id'],
clientRequestId,
status: 500,
code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.completed',
}),
]);
expect(logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
method: 'POST',
route: '/api/v1/auth/login',
status: 500,
code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.completed',
}),
]),
);
expect(logs).toEqual(
expect.arrayContaining([
expect.objectContaining({
clientRequestId,
code: 'INTERNAL_SERVER_ERROR',
level: 50,
message: 'http.request.unhandled',
errorName: 'Error',
errorStack: expect.stringContaining(' at ') as unknown,
}),
]),
);
expect(JSON.stringify(logs)).not.toContain(
'SYNTHETIC_AUDIT_SECRET',
);
+15 -1
View File
@@ -16,6 +16,7 @@ import {
BankSummaryDto,
ExpenseCategoryDto,
} from '../src/modules/banking/bank-cards/presentation/bank-card.dto';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
jest.setTimeout(60000);
@@ -77,7 +78,10 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
const start = async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
})
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication();
configureApp(app);
await app.listen(0, '127.0.0.1');
@@ -369,6 +373,8 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
expect((await getCard(own.id)).balance.value).toBe('1000');
});
it('renames default category without role loss; duplicates and stale rename conflict', async () => {
// Defaults are seeded by the first write of the user; this case must not depend on earlier cases.
await createCard();
const category = (await categories()).find((c) => c.isDefault);
assert.ok(category);
await auth('patch', '/categories/' + category.id)
@@ -725,11 +731,19 @@ describe('Manual bank cards configured HTTP and real SQLite', () => {
expect(await getCard(card.id)).toEqual(before);
expect((await getOp(refund.id)).originalExpenseId).toBe(purchase.id);
await auth('post', '/operations').send(deletedBody).expect(410);
// The rename is this case's own prerequisite, not an effect of an earlier case.
const defaultCategory = (await categories()).find((c) => c.isDefault);
assert.ok(defaultCategory);
await auth('patch', '/categories/' + defaultCategory.id)
.send({ version: defaultCategory.version, name: 'Остальное' })
.expect(200);
expect((await categories()).find((c) => c.isDefault)?.name).toBe(
'Остальное',
);
});
it('enforces actual FK and unique constraints', async () => {
// Seeds the owner's default categories so the unique-name check has a real row to collide with.
await createCard();
await expect(
db.expenseCategory.create({
data: {
+47
View File
@@ -1,5 +1,6 @@
import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import {
ConflictException,
INestApplication,
@@ -15,6 +16,14 @@ import { configureApp } from '../src/main';
import { BondCatalogService } from '../src/modules/investments/instruments/bonds/application/bond-catalog.service';
import { BondIncomeService } from '../src/modules/investments/instruments/bonds/application/bond-income.service';
import { BondMarketService } from '../src/modules/investments/instruments/bonds/application/bond-market.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import {
bootOnlineApp,
expectSafeBody,
failUpstream,
onlineUserId,
refusals,
} from './online-upstream';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Bond HTTP contract', () => {
@@ -38,6 +47,8 @@ describe('Bond HTTP contract', () => {
.useValue(bonds)
.overrideProvider(BondIncomeService)
.useValue(bonds)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication();
configureApp(app);
@@ -198,3 +209,39 @@ describe('Bond HTTP contract', () => {
expect(response.body).not.toHaveProperty('stack');
});
});
// Real services and TbankClient error mapping; only the gRPC transport is stubbed.
// Bonds expose no company or screener route; their online entrypoints are search, detail and ticker.
describe('Bond online reads: upstream failure translation', () => {
const rpc = jest.fn();
let ctx: Awaited<ReturnType<typeof bootOnlineApp>>;
const get = (path: string) => request(ctx.app.getHttpServer()).get(path);
// [entrypoint, request, RPC that fails second]: a failure after the first call must translate too.
const entrypoints: [string, () => request.Test, string][] = [
['search', () => get('/api/v1/bonds?query=SBER'), 'getLastPrices'],
['detail', () => get(`/api/v1/bonds/${uid}`), 'bondBy'],
['ticker', () => get('/api/v1/bonds/by-ticker/SBER'), 'bondBy'],
];
const cases = entrypoints.flatMap(([name, send, later]) =>
refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)),
);
beforeAll(async () => {
ctx = await bootOnlineApp(rpc);
});
afterAll(() => ctx.app.close());
it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => {
failUpstream(rpc, 'bond', code, at);
const response = await send().set('Authorization', ctx.bearer).expect(400);
expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN');
const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } });
expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision });
});
it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => {
failUpstream(rpc, 'bond', status.UNAVAILABLE);
const response = await send().set('Authorization', ctx.bearer).expect(503);
expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE');
});
});
+2 -1
View File
@@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
[controller, 'import { PrismaService } from "../../../prisma/prisma.service";'],
[controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'],
[useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'],
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
@@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur
[controller, 'import { AuthService } from "../application/auth.service";'],
[useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'],
[useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'],
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'],
['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'],
]) {
const [result] = await eslint.lintText(code, { filePath });
assert.equal(result.fatalErrorCount, 0);
+87
View File
@@ -0,0 +1,87 @@
import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import { INestApplication } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';
import { Test } from '@nestjs/testing';
import { AppModule } from '../src/app.module';
import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service';
import { TbankClient } from '../src/integrations/tbank/tbank.client';
import { configureApp } from '../src/main';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import { PrismaService } from '../src/prisma/prisma.service';
export const onlineUserId = 'online-reader';
export const onlineToken = 'synthetic-online-token';
export const rawUpstreamText = 'raw upstream refusal text';
export const refusals = [status.UNAUTHENTICATED, status.PERMISSION_DENIED];
// Real TbankClient with only the transport stubbed: the gRPC status goes through its real error mapping.
export async function bootOnlineApp(rpc: jest.Mock) {
const upstream = Object.assign(Object.create(TbankClient.prototype) as TbankClient, {
logger: { event: jest.fn() },
queue: { run: (operation: () => Promise<unknown>) => operation(), size: 0 },
request: rpc,
});
const module = await Test.createTestingModule({ imports: [AppModule] })
.overrideProvider(TbankClient)
.useValue(upstream)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
const app: INestApplication<Server> = module.createNestApplication();
configureApp(app);
await app.init();
await app.listen(0, '127.0.0.1');
const prisma = app.get(PrismaService);
await prisma.user.create({
data: { id: onlineUserId, email: 'online@example.invalid', passwordHash: 'synthetic' },
});
const connection = await prisma.tbankConnection.create({
data: {
userId: onlineUserId,
encryptedToken: app.get(TbankTokenService).encrypt(onlineToken),
connectedAt: new Date(),
},
});
const bearer = `Bearer ${new JwtService().sign(
{ sub: onlineUserId, type: 'access' },
{ secret: process.env.JWT_SECRET },
)}`;
return { app, bearer, connection, prisma };
}
// Healthy answers for every RPC an online entrypoint uses; `at` picks the single RPC that fails.
export function failUpstream(rpc: jest.Mock, kind: 'bond' | 'share', code: number, at?: string) {
const instrument = {
apiTradeAvailableFlag: true,
assetUid: 'asset-uid',
instrumentKind: kind === 'bond' ? 'INSTRUMENT_TYPE_BOND' : 'INSTRUMENT_TYPE_SHARE',
ticker: 'SBER',
uid: 'e6123145-9665-43e0-8413-cd61b8aa9b13',
};
const healthy = new Map<string, unknown>([
['bondBy', { instrument }],
['findInstrument', { instruments: [instrument] }],
['getAssetBy', { asset: { description: 'description' } }],
['getAssetFundamentals', { fundamentals: [] }],
['getLastPrices', { lastPrices: [] }],
['shareBy', { instrument }],
]);
const failure = Object.assign(new Error('grpc failure'), {
code,
details: `${rawUpstreamText} Bearer ${onlineToken}`,
});
rpc.mockReset();
rpc.mockImplementation((_token: string, _client: unknown, method: string) =>
!at || method === at ? Promise.reject(failure) : Promise.resolve(healthy.get(method) ?? {}),
);
}
// Safe public error: stable code only, no upstream text, no credentials.
export function expectSafeBody(body: unknown, status: number, code: string) {
expect(body).toEqual({ code, status });
expect(JSON.stringify(body)).not.toContain(rawUpstreamText);
expect(JSON.stringify(body)).not.toContain(onlineToken);
}
+74 -2
View File
@@ -77,7 +77,7 @@ describe('OpenAPI contract (integration)', () => {
}
});
it('exposes required semantic income dependency roles', () => {
it('exposes only the selected income dependency role and no comparison data', () => {
const schemas = createOpenApiDocument(app).components?.schemas;
expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({
properties: {
@@ -100,12 +100,84 @@ describe('OpenAPI contract (integration)', () => {
expect(dependency.required).toContain('role');
expect(dependency).toMatchObject({
properties: {
role: { type: 'string', enum: ['selected', 'previous', 'year'] },
role: { type: 'string', enum: ['selected'] },
},
});
const analytics = schemas?.IncomeAnalyticsDto;
assert(analytics && !('$ref' in analytics));
expect(analytics.required).toEqual(
expect.arrayContaining([
'accountId',
'period',
'totalsByCurrency',
'calculatedAt',
'bucketGranularity',
'buckets',
'instruments',
'entries',
]),
);
expect(analytics.properties).not.toHaveProperty('previous');
expect(analytics.properties).not.toHaveProperty('yearComparison');
});
it('publishes every emitted problem detail code as a named enum', () => {
const schemas = createOpenApiDocument(app).components?.schemas;
const expectedCodes = [
'BAD_REQUEST',
'INTERNAL_SERVER_ERROR',
'NOT_FOUND',
'UNAUTHORIZED',
'UNSUPPORTED_MEDIA_TYPE',
'VALIDATION_ERROR',
'EMAIL_ALREADY_REGISTERED',
'INVALID_CREDENTIALS',
'INVALID_OPERATIONS_CURSOR',
'BOND_HISTORY_START_UNAVAILABLE',
'BOND_TICKER_AMBIGUOUS',
'STOCK_HISTORY_START_UNAVAILABLE',
'STOCK_TICKER_AMBIGUOUS',
'INVALID_TBANK_TOKEN',
'TBANK_NOT_CONNECTED',
'TBANK_UNAVAILABLE',
'BANK_CARD_ARCHIVED',
'BANK_DEPENDENT_REFUNDS',
'BANK_DUPLICATE_CATEGORY',
'BANK_IMMUTABLE_OPERATION',
'BANK_INTENT_CONFLICT',
'BANK_INVALID_DATE',
'BANK_INVALID_INPUT',
'BANK_INVALID_MONEY',
'BANK_INVALID_REFUND',
'BANK_NOT_FOUND',
'BANK_OPENING_DATE_CONFLICT',
'BANK_OPERATION_DELETED',
'BANK_REFUND_LIMIT',
'BANK_VERSION_CONFLICT',
'BANK_WRITE_CONFLICT',
'MOEX_UNAVAILABLE',
'NCR_UNAVAILABLE',
'INVALID_EVENTS_PERIOD',
'INVALID_INCOME_PERIOD',
];
const problem = schemas?.ProblemDetailsDto;
assert(problem && !('$ref' in problem));
const codeSchema = problem.properties?.code;
assert(codeSchema && !('$ref' in codeSchema));
expect(codeSchema.type).toBe('string');
expect(codeSchema.enum).toEqual(expect.arrayContaining(expectedCodes));
expect(codeSchema.enum).toHaveLength(expectedCodes.length);
});
it('generates the runtime contract without server startup or deployment configuration', async () => {
const schemas = createOpenApiDocument(app).components?.schemas;
expect(schemas?.StockScreenerCriteriaDto).toMatchObject({
additionalProperties: false,
});
expect(schemas?.StockScreenerItemDto).toMatchObject({
properties: { lot: { type: 'integer', minimum: 1, nullable: true } },
});
expect(
createOpenApiDocument(app).components?.schemas?.ProblemDetailsDto,
).toMatchObject({
+1 -1
View File
@@ -18,7 +18,7 @@ writeFileSync(sentinel, 'fictional user database: must never be opened');
const before = readFileSync(sentinel);
const databases = new Set();
async function check(name, overrides = {}, signal) {
const child = spawn(process.execPath, ['test/sqlite.cjs', '--all'], {
const child = spawn(process.execPath, ['test/sqlite.cjs'], {
cwd,
env: {
...process.env,
+48
View File
@@ -1,5 +1,6 @@
import type { Server } from 'node:http';
import { status } from '@grpc/grpc-js';
import {
ConflictException,
INestApplication,
@@ -16,6 +17,14 @@ import { StockCatalogService } from '../src/modules/investments/instruments/stoc
import { StockIncomeService } from '../src/modules/investments/instruments/stocks/application/stock-income.service';
import { StockMarketService } from '../src/modules/investments/instruments/stocks/application/stock-market.service';
import { StockScreenerService } from '../src/modules/investments/instruments/stocks/application/stock-screener.service';
import { TbankHourlyScheduler } from '../src/modules/investments/tbank-sync/tbank-hourly.scheduler';
import {
bootOnlineApp,
expectSafeBody,
failUpstream,
onlineUserId,
refusals,
} from './online-upstream';
const uid = 'e6123145-9665-43e0-8413-cd61b8aa9b13';
describe('Stock HTTP contract', () => {
@@ -41,6 +50,8 @@ describe('Stock HTTP contract', () => {
.useValue(stocks)
.overrideProvider(StockScreenerService)
.useValue(screener)
.overrideProvider(TbankHourlyScheduler)
.useValue({ tick: jest.fn() })
.compile();
app = module.createNestApplication();
configureApp(app);
@@ -198,3 +209,40 @@ describe('Stock HTTP contract', () => {
expect(response.body).not.toHaveProperty('stack');
});
});
// Real services and TbankClient error mapping; only the gRPC transport is stubbed.
describe('Stock online reads: upstream failure translation', () => {
const rpc = jest.fn();
let ctx: Awaited<ReturnType<typeof bootOnlineApp>>;
const get = (path: string) => request(ctx.app.getHttpServer()).get(path);
// [entrypoint, request, RPC that fails second]: a failure after the first call must translate too.
const entrypoints: [string, () => request.Test, string][] = [
['search', () => get('/api/v1/stocks?query=SBER'), 'getLastPrices'],
['detail', () => get(`/api/v1/stocks/${uid}`), 'shareBy'],
['ticker', () => get('/api/v1/stocks/by-ticker/SBER'), 'shareBy'],
['company', () => get(`/api/v1/stocks/${uid}/company`), 'getAssetBy'],
['screener', () => request(ctx.app.getHttpServer()).post('/api/v1/stocks/screener/search').send({}), 'shares'],
];
const cases = entrypoints.flatMap(([name, send, later]) =>
refusals.flatMap((code) => [undefined, later].map((at) => [name, code, at, send] as const)),
);
beforeAll(async () => {
ctx = await bootOnlineApp(rpc);
});
afterAll(() => ctx.app.close());
it.each(cases)('%s: upstream auth refusal %s (failing RPC %s) becomes 400/INVALID_TBANK_TOKEN', async (_name, code, at, send) => {
failUpstream(rpc, 'share', code, at);
const response = await send().set('Authorization', ctx.bearer).expect(400);
expectSafeBody(response.body, 400, 'INVALID_TBANK_TOKEN');
const saved = await ctx.prisma.tbankConnection.findUniqueOrThrow({ where: { userId: onlineUserId } });
expect(saved).toMatchObject({ invalidToken: false, revision: ctx.connection.revision });
});
it.each(entrypoints)('%s: temporary unavailability stays 503/TBANK_UNAVAILABLE', async (_name, send) => {
failUpstream(rpc, 'share', status.UNAVAILABLE);
const response = await send().set('Authorization', ctx.bearer).expect(503);
expectSafeBody(response.body, 503, 'TBANK_UNAVAILABLE');
});
});

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