Compare commits
34
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
809936e62d | ||
|
|
85d65f8997 | ||
|
|
fee61537b6 | ||
|
|
84433ee207 | ||
|
|
d792baaf59 | ||
|
|
83b515a453 | ||
|
|
07cfd08df9 | ||
|
|
e3ecb8c15e | ||
|
|
61c55256e7 | ||
|
|
cea77f6d56 | ||
|
|
b3d4801cfe | ||
|
|
b3ca11d141 | ||
|
|
efac7201e0 | ||
|
|
85a71967fa | ||
|
|
cd78b43783 | ||
|
|
deda7ffc2c | ||
|
|
5d31b3e6dd | ||
|
|
c5ccfeadea | ||
|
|
02e0d560c8 | ||
|
|
fa0a6e5603 | ||
|
|
bade5fae1c | ||
|
|
1a5de9bebd | ||
|
|
aeb4647fda | ||
|
|
169bee1da5 | ||
|
|
554410a271 | ||
|
|
a80bbbf1e2 | ||
|
|
a18d9dbdc8 | ||
|
|
17432dfee3 | ||
|
|
5e84e1e5f1 | ||
|
|
e350146749 | ||
|
|
99b137ed0b | ||
|
|
d37fa2c903 | ||
|
|
5295945ab2 | ||
|
|
22668da3fa |
+6
-6
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
|
|||||||
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
||||||
чистые расчёты portfolio/banking остаются у domain owner.
|
чистые расчёты portfolio/banking остаются у domain owner.
|
||||||
|
|
||||||
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
|
PrismaService владеет DB lifecycle. Application services/use cases получают
|
||||||
через concrete repositories; существующие связные CRUD/transaction services
|
данные через concrete repositories; запросы и CAS живут в infrastructure
|
||||||
сохраняют прямые queries по [data convention](conventions/data.md).
|
по [data convention](conventions/data.md).
|
||||||
Money — currency + decimal string; расчёты используют точные общие helpers.
|
Money — currency + decimal string; расчёты используют точные общие helpers.
|
||||||
|
|
||||||
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
||||||
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
|
|||||||
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||||
может использовать application types/input и domain rules; новые interfaces
|
может использовать application types/input и domain rules; новые interfaces
|
||||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
infrastructure. Application не строит Prisma queries: use case и связный CRUD
|
||||||
по [Data](conventions/data.md); use case получает данные через repository и сам
|
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
|
||||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
на каждую таблицу не требуется.
|
||||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||||
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||||
|
|
||||||
|
|||||||
+10
-10
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
|
|||||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||||
не становятся domain entities: расчёт получает только нужные ему значения.
|
не становятся domain entities: расчёт получает только нужные ему значения.
|
||||||
|
|
||||||
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
|
Application-код не импортирует PrismaService и не строит Prisma queries.
|
||||||
services (auth, lifecycle подключения, изменения карт/операций/категорий)
|
Репозиторий владеет запросом и CAS-проверкой результата, а application —
|
||||||
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
|
правилами и формированием ответа. Типы Prisma допустимы только в type-only
|
||||||
лишь набора pass-through методов. Новая самостоятельная read/write capability
|
импортах для существующей транзакционной границы. Не создавай обёртку
|
||||||
получает repository method; самостоятельный use case не выполняет Prisma queries.
|
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
|
||||||
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
|
сохраняет retry/transaction boundary для банковских записей; callback с
|
||||||
существующих banking services. Такой callback с Prisma transaction client —
|
transaction client — локальная техническая граница, не API для domain.
|
||||||
локальная техническая граница этих services, не API для domain или use cases.
|
|
||||||
|
|
||||||
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
|
||||||
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
|
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
|
||||||
|
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||||
|
|
||||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ jobs:
|
|||||||
- name: Audit runtime and development dependencies
|
- name: Audit runtime and development dependencies
|
||||||
run: |
|
run: |
|
||||||
npm audit --omit=dev --audit-level=low
|
npm audit --omit=dev --audit-level=low
|
||||||
npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c
|
npm audit --include=dev --audit-level=high
|
||||||
|
|
||||||
verify:
|
verify:
|
||||||
needs: [commits, security]
|
needs: [commits, security]
|
||||||
|
|||||||
@@ -1,5 +1,66 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
|
||||||
|
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
|
||||||
|
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
|
||||||
|
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
|
||||||
|
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
|
||||||
|
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
|
||||||
|
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
|
||||||
|
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
|
||||||
|
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
|
||||||
|
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
|
||||||
|
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
|
||||||
|
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
|
||||||
|
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
|
||||||
|
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
|
||||||
|
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
|
||||||
|
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
|
||||||
|
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
|
||||||
|
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
|
||||||
|
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
|
||||||
|
|
||||||
|
### CI
|
||||||
|
|
||||||
|
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
|
||||||
|
|
||||||
|
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
* extend account list portfolio summary ([#138](https://git.personal.ksv741.ru/WealthLedger/backend/issues/138)) ([97fc41f](https://git.personal.ksv741.ru/WealthLedger/backend/commit/97fc41fa295a7a943a27171fa41cdaf286632956))
|
||||||
|
* **portfolio:** store and publish instrument sector and issuer (WealthLedger/main[#48](https://git.personal.ksv741.ru/WealthLedger/backend/issues/48)) ([#148](https://git.personal.ksv741.ru/WealthLedger/backend/issues/148)) ([17432df](https://git.personal.ksv741.ru/WealthLedger/backend/commit/17432dfee3d7d06841caea4419a062e2a4bfc82c))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* count brokerage cash once and skip short-position return percent (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([f2843e3](https://git.personal.ksv741.ru/WealthLedger/backend/commit/f2843e30bda5442158ec145cc63a5491506bb60f))
|
||||||
|
* reset portfolio value points inflated by double-counted cash (WealthLedger/backend[#140](https://git.personal.ksv741.ru/WealthLedger/backend/issues/140)) ([2325b63](https://git.personal.ksv741.ru/WealthLedger/backend/commit/2325b638839240bf5d6d4f7e6e1f8eaa82d4d7fd))
|
||||||
|
* scope income and ledger sync to requested intervals (WealthLedger/backend[#57](https://git.personal.ksv741.ru/WealthLedger/backend/issues/57)) ([#147](https://git.personal.ksv741.ru/WealthLedger/backend/issues/147)) ([5e84e1e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5e84e1e5f1943b5af1992a3a946194b27fc1a1d5))
|
||||||
|
* use available Debian packages in image build ([#129](https://git.personal.ksv741.ru/WealthLedger/backend/issues/129)) ([a250142](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a250142a86e07ec7047e642afa16492fa4987f9a))
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
* expose tbank-sync public api and guard its boundary (WealthLedger/backend[#118](https://git.personal.ksv741.ru/WealthLedger/backend/issues/118)) ([#143](https://git.personal.ksv741.ru/WealthLedger/backend/issues/143)) ([5295945](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5295945ab237a6e94aa64d7bb54bfd04554ca9f9))
|
||||||
|
* publish typed problem detail codes (WealthLedger/backend[#117](https://git.personal.ksv741.ru/WealthLedger/backend/issues/117)) ([#144](https://git.personal.ksv741.ru/WealthLedger/backend/issues/144)) ([d37fa2c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d37fa2c9034e90e9d22d4022c45fc36c22dd2ab3))
|
||||||
|
* remove tbank-sync operations import cycle (WealthLedger/backend[#116](https://git.personal.ksv741.ru/WealthLedger/backend/issues/116)) ([#142](https://git.personal.ksv741.ru/WealthLedger/backend/issues/142)) ([22668da](https://git.personal.ksv741.ru/WealthLedger/backend/commit/22668da3fa0666bdd22737913379c99d4cf01993))
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
* cover portfolio publish rollback and claim fence (WealthLedger/backend[#105](https://git.personal.ksv741.ru/WealthLedger/backend/issues/105)) ([#146](https://git.personal.ksv741.ru/WealthLedger/backend/issues/146)) ([e350146](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e350146749293f2eaf8060f7626ff3c1cd8edf2c))
|
||||||
|
* replace operations sync delays with explicit barriers (WealthLedger/backend[#104](https://git.personal.ksv741.ru/WealthLedger/backend/issues/104)) ([#145](https://git.personal.ksv741.ru/WealthLedger/backend/issues/145)) ([99b137e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/99b137ed0b39ab73b6a28d1a494d2d98bf8fa380))
|
||||||
|
|
||||||
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
|
## [2.3.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.0...v2.3.1) (2026-10-24)
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -53,11 +53,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 1
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -92,11 +87,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/investment-sync-plan.ts": {
|
"src/modules/investments/investment-sync-plan.ts": {
|
||||||
"sonarjs/cognitive-complexity": {
|
|
||||||
"count": 1
|
|
||||||
},
|
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 4
|
"count": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
"src/modules/investments/operations/application/brokerage-operations.service.ts": {
|
||||||
@@ -104,7 +96,7 @@
|
|||||||
"count": 2
|
"count": 2
|
||||||
},
|
},
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 9
|
"count": 6
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
||||||
@@ -138,11 +130,6 @@
|
|||||||
"count": 1
|
"count": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
|
||||||
"sonarjs/no-nested-conditional": {
|
|
||||||
"count": 6
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"test/bank-cards-durability.cjs": {
|
"test/bank-cards-durability.cjs": {
|
||||||
"sonarjs/no-nested-template-literals": {
|
"sonarjs/no-nested-template-literals": {
|
||||||
"count": 1
|
"count": 1
|
||||||
@@ -161,9 +148,6 @@
|
|||||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||||
"sonarjs/no-nested-conditional": {
|
"sonarjs/no-nested-conditional": {
|
||||||
"count": 1
|
"count": 1
|
||||||
},
|
|
||||||
"sonarjs/no-nested-template-literals": {
|
|
||||||
"count": 3
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+27
-17
@@ -15,6 +15,18 @@ import globals from 'globals';
|
|||||||
import tseslint from 'typescript-eslint';
|
import tseslint from 'typescript-eslint';
|
||||||
|
|
||||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||||
|
const guardedModules = [
|
||||||
|
{
|
||||||
|
name: 'tbank-sync',
|
||||||
|
except: [
|
||||||
|
'./tbank-sync.api.ts',
|
||||||
|
'./tbank-sync.dto.ts',
|
||||||
|
'./tbank-sync.module.ts',
|
||||||
|
'./tbank-sync-repository.module.ts',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
|
||||||
|
];
|
||||||
const prismaImports = [
|
const prismaImports = [
|
||||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||||
];
|
];
|
||||||
@@ -56,6 +68,7 @@ export default defineConfig(
|
|||||||
'after',
|
'after',
|
||||||
{ overrides: { '?': 'before', ':': 'before' } },
|
{ overrides: { '?': 'before', ':': 'before' } },
|
||||||
],
|
],
|
||||||
|
'import-x/no-cycle': 'error',
|
||||||
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
'import-x/no-duplicates': ['error', { 'prefer-inline': true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -157,13 +170,14 @@ export default defineConfig(
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/**/*.use-case.ts'],
|
files: ['src/**/application/**/*.ts'],
|
||||||
ignores: tests,
|
ignores: tests,
|
||||||
rules: {
|
rules: {
|
||||||
'no-restricted-imports': ['error', {
|
'no-restricted-imports': ['error', {
|
||||||
patterns: [{
|
patterns: [{
|
||||||
group: prismaImports,
|
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,24 +225,20 @@ export default defineConfig(
|
|||||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
'@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.
|
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
|
||||||
|
// A module may import its own internals, so each guarded module gets a block without its zone.
|
||||||
|
...[undefined, ...guardedModules].map((own) => ({
|
||||||
|
files: own ? [`src/modules/investments/${own.name}/**/*.ts`] : ['src/**/*.ts'],
|
||||||
|
ignores: own ? tests : [...tests, ...guardedModules.map(({ name }) => `src/modules/investments/${name}/**`)],
|
||||||
|
rules: {
|
||||||
'import-x/no-restricted-paths': ['error', {
|
'import-x/no-restricted-paths': ['error', {
|
||||||
zones: [{
|
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
|
||||||
target: './src',
|
target: './src',
|
||||||
from: './src/modules/investments/tbank-sync',
|
from: `./src/modules/investments/${name}`,
|
||||||
except: [
|
except,
|
||||||
'./tbank-sync.api.ts',
|
message: `Use ${name}.api for cross-module access.`,
|
||||||
'./tbank-sync.dto.ts',
|
})),
|
||||||
'./tbank-sync.module.ts',
|
|
||||||
'./tbank-sync-repository.module.ts',
|
|
||||||
],
|
|
||||||
message: 'Use tbank-sync.api for cross-module access.',
|
|
||||||
}],
|
|
||||||
}],
|
}],
|
||||||
},
|
},
|
||||||
},
|
})),
|
||||||
);
|
);
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.3.1",
|
"version": "2.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.3.1",
|
"version": "2.4.1",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@grpc/grpc-js": "1.14.6",
|
"@grpc/grpc-js": "1.14.6",
|
||||||
|
|||||||
+17
-3
@@ -1,9 +1,19 @@
|
|||||||
{
|
{
|
||||||
"name": "wealth-ledger-backend",
|
"name": "wealth-ledger-backend",
|
||||||
"version": "2.3.1",
|
"version": "2.4.1",
|
||||||
"description": "",
|
"description": "",
|
||||||
"author": "",
|
"author": "",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"imports": {
|
||||||
|
"#generated/*": {
|
||||||
|
"development": "./dist-dev/generated/*",
|
||||||
|
"default": "./dist/generated/*"
|
||||||
|
},
|
||||||
|
"#src/*": {
|
||||||
|
"development": "./dist-dev/src/*",
|
||||||
|
"default": "./dist/src/*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "v24.22.1"
|
"node": "v24.22.1"
|
||||||
@@ -14,8 +24,8 @@
|
|||||||
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||||
"format": "npm run lint:fix",
|
"format": "npm run lint:fix",
|
||||||
"start": "nest start",
|
"start": "nest start",
|
||||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
|
||||||
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
"start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
|
||||||
"start:prod": "node dist/src/main.js",
|
"start:prod": "node dist/src/main.js",
|
||||||
"lint": "npm run lint:check",
|
"lint": "npm run lint:check",
|
||||||
"lint:check": "eslint . --max-warnings=0",
|
"lint:check": "eslint . --max-warnings=0",
|
||||||
@@ -131,6 +141,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"jest": {
|
"jest": {
|
||||||
|
"moduleNameMapper": {
|
||||||
|
"^#generated/(.*)$": "<rootDir>/../generated/$1",
|
||||||
|
"^#src/(.*)$": "<rootDir>/$1"
|
||||||
|
},
|
||||||
"moduleFileExtensions": [
|
"moduleFileExtensions": [
|
||||||
"js",
|
"js",
|
||||||
"json",
|
"json",
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
CREATE TABLE "MarketIndexDailyClose" (
|
||||||
|
"secid" TEXT NOT NULL,
|
||||||
|
"tradeDate" TEXT NOT NULL,
|
||||||
|
"close" TEXT NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "MarketIndexDailyClose_pkey" PRIMARY KEY ("secid","tradeDate")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE "MarketIndexSyncState" (
|
||||||
|
"secid" TEXT NOT NULL PRIMARY KEY,
|
||||||
|
"lastAttemptAt" DATETIME,
|
||||||
|
"lastSuccessAt" DATETIME,
|
||||||
|
"coveredFrom" TEXT,
|
||||||
|
"coveredTo" TEXT
|
||||||
|
);
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- RedefineTables
|
||||||
|
PRAGMA defer_foreign_keys=ON;
|
||||||
|
PRAGMA foreign_keys=OFF;
|
||||||
|
CREATE TABLE "new_TbankPortfolioValuePoint" (
|
||||||
|
"userId" TEXT NOT NULL,
|
||||||
|
"accountId" TEXT NOT NULL,
|
||||||
|
"attemptId" TEXT NOT NULL,
|
||||||
|
"value" TEXT NOT NULL,
|
||||||
|
"capturedAt" DATETIME NOT NULL,
|
||||||
|
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
|
||||||
|
PRIMARY KEY ("userId", "accountId", "attemptId"),
|
||||||
|
CONSTRAINT "TbankPortfolioValuePoint_userId_accountId_fkey" FOREIGN KEY ("userId", "accountId") REFERENCES "TbankPortfolioValueHistory" ("userId", "accountId") ON DELETE CASCADE ON UPDATE RESTRICT
|
||||||
|
);
|
||||||
|
-- RUB-only: keep the RUB row per key; non-RUB rows are dropped.
|
||||||
|
INSERT INTO "new_TbankPortfolioValuePoint" ("accountId", "attemptId", "capturedAt", "createdAt", "userId", "value")
|
||||||
|
SELECT "accountId", "attemptId", "capturedAt", "createdAt", "userId", "value"
|
||||||
|
FROM "TbankPortfolioValuePoint"
|
||||||
|
WHERE "currency" = 'RUB';
|
||||||
|
DROP TABLE "TbankPortfolioValuePoint";
|
||||||
|
ALTER TABLE "new_TbankPortfolioValuePoint" RENAME TO "TbankPortfolioValuePoint";
|
||||||
|
CREATE INDEX "TbankPortfolioValuePoint_userId_accountId_capturedAt_idx" ON "TbankPortfolioValuePoint"("userId", "accountId", "capturedAt");
|
||||||
|
|
||||||
|
-- RUB-only: persisted JSON payloads written in the old multi-currency shape
|
||||||
|
-- are stale. A missing snapshot/forecast is the app's normal "not synced yet"
|
||||||
|
-- state: reads report availability 'missing' and the hourly walk rebuilds them
|
||||||
|
-- on the next pass. Delete the stale rows instead of failing reads with 500.
|
||||||
|
DELETE FROM "TbankPortfolio";
|
||||||
|
DELETE FROM "TbankForecast";
|
||||||
|
|
||||||
|
PRAGMA foreign_keys=ON;
|
||||||
|
PRAGMA defer_foreign_keys=OFF;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- RUB-only: snapshots were dropped by the previous migration; clearing the hourly
|
||||||
|
-- schedule makes the next scheduler tick rebuild them instead of waiting for the term.
|
||||||
|
DELETE FROM "TbankSyncWork" WHERE "scopeKey" = 'hourly';
|
||||||
+34
-3
@@ -8,6 +8,24 @@ datasource db {
|
|||||||
provider = "sqlite"
|
provider = "sqlite"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public MOEX index closes contain no user holdings or credentials.
|
||||||
|
model MarketIndexDailyClose {
|
||||||
|
secid String
|
||||||
|
tradeDate String
|
||||||
|
close String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
@@id([secid, tradeDate])
|
||||||
|
}
|
||||||
|
|
||||||
|
model MarketIndexSyncState {
|
||||||
|
secid String @id
|
||||||
|
lastAttemptAt DateTime?
|
||||||
|
lastSuccessAt DateTime?
|
||||||
|
coveredFrom String?
|
||||||
|
coveredTo String?
|
||||||
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id
|
id String @id
|
||||||
email String @unique
|
email String @unique
|
||||||
@@ -83,18 +101,17 @@ model TbankPortfolioValueHistory {
|
|||||||
@@id([userId, accountId])
|
@@id([userId, accountId])
|
||||||
}
|
}
|
||||||
|
|
||||||
// One row per currency of a fully valued successful portfolio attempt.
|
// One row per fully valued successful portfolio attempt (RUB only).
|
||||||
model TbankPortfolioValuePoint {
|
model TbankPortfolioValuePoint {
|
||||||
userId String
|
userId String
|
||||||
accountId String
|
accountId String
|
||||||
attemptId String
|
attemptId String
|
||||||
currency String
|
|
||||||
value String
|
value String
|
||||||
capturedAt DateTime
|
capturedAt DateTime
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
history TbankPortfolioValueHistory @relation(fields: [userId, accountId], references: [userId, accountId], onDelete: Cascade, onUpdate: Restrict)
|
||||||
|
|
||||||
@@id([userId, accountId, attemptId, currency])
|
@@id([userId, accountId, attemptId])
|
||||||
@@index([userId, accountId, capturedAt])
|
@@index([userId, accountId, capturedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -186,6 +203,20 @@ model TbankInstrumentLookup {
|
|||||||
@@id([provider, identifierType, identifier])
|
@@id([provider, identifierType, identifier])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Public per-instrument classification; no holdings or credentials.
|
||||||
|
model TbankInstrumentClassification {
|
||||||
|
provider String
|
||||||
|
instrumentKey String
|
||||||
|
sector String?
|
||||||
|
sectorFetchedAt DateTime?
|
||||||
|
assetUid String?
|
||||||
|
brandUid String?
|
||||||
|
brandName String?
|
||||||
|
brandFetchedAt DateTime?
|
||||||
|
|
||||||
|
@@id([provider, instrumentKey])
|
||||||
|
}
|
||||||
|
|
||||||
// Source records contain no holdings, personal estimates or credentials errors.
|
// Source records contain no holdings, personal estimates or credentials errors.
|
||||||
model TbankInstrumentSource {
|
model TbankInstrumentSource {
|
||||||
provider String
|
provider String
|
||||||
|
|||||||
+30
-1
@@ -2,7 +2,8 @@
|
|||||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
"extends": [
|
"extends": [
|
||||||
"config:recommended",
|
"config:recommended",
|
||||||
":semanticCommitTypeAll(chore)"
|
":semanticCommitTypeAll(chore)",
|
||||||
|
"security:minimumReleaseAgeNpm"
|
||||||
],
|
],
|
||||||
"timezone": "Europe/Moscow",
|
"timezone": "Europe/Moscow",
|
||||||
"labels": [
|
"labels": [
|
||||||
@@ -10,6 +11,13 @@
|
|||||||
],
|
],
|
||||||
"dependencyDashboard": true,
|
"dependencyDashboard": true,
|
||||||
"rangeStrategy": "pin",
|
"rangeStrategy": "pin",
|
||||||
|
"prConcurrentLimit": 5,
|
||||||
|
"lockFileMaintenance": {
|
||||||
|
"enabled": true,
|
||||||
|
"schedule": [
|
||||||
|
"before 4am on the first day of the month"
|
||||||
|
]
|
||||||
|
},
|
||||||
"packageRules": [
|
"packageRules": [
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
@@ -18,6 +26,27 @@
|
|||||||
],
|
],
|
||||||
"groupName": "non-major dependencies"
|
"groupName": "non-major dependencies"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"github-actions"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major github actions"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"matchManagers": [
|
||||||
|
"docker-compose",
|
||||||
|
"dockerfile"
|
||||||
|
],
|
||||||
|
"matchUpdateTypes": [
|
||||||
|
"minor",
|
||||||
|
"patch"
|
||||||
|
],
|
||||||
|
"groupName": "non-major container images"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"matchUpdateTypes": [
|
"matchUpdateTypes": [
|
||||||
"major"
|
"major"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BrokerageAccountsModule } from './modules/investments/brokerage-account
|
|||||||
import { IncomeModule } from './modules/investments/income/income.module';
|
import { IncomeModule } from './modules/investments/income/income.module';
|
||||||
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
import { InstrumentsModule } from './modules/investments/instruments/instruments.module';
|
||||||
import { OperationsModule } from './modules/investments/operations/operations.module';
|
import { OperationsModule } from './modules/investments/operations/operations.module';
|
||||||
|
import { PerformanceModule } from './modules/investments/performance/performance.module';
|
||||||
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
import { PortfolioModule } from './modules/investments/portfolio/portfolio.module';
|
||||||
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
import { TbankConnectionModule } from './modules/investments/tbank-connection/tbank-connection.module';
|
||||||
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
import { TbankSyncModule } from './modules/investments/tbank-sync/tbank-sync.module';
|
||||||
@@ -27,6 +28,7 @@ import { PrismaModule } from './prisma/prisma.module';
|
|||||||
BankCardsModule,
|
BankCardsModule,
|
||||||
BrokerageAccountsModule,
|
BrokerageAccountsModule,
|
||||||
PortfolioModule,
|
PortfolioModule,
|
||||||
|
PerformanceModule,
|
||||||
OperationsModule,
|
OperationsModule,
|
||||||
IncomeModule,
|
IncomeModule,
|
||||||
UpcomingEventsModule,
|
UpcomingEventsModule,
|
||||||
|
|||||||
@@ -8,6 +8,73 @@ const emptyEvents = {
|
|||||||
|
|
||||||
describe('MoexClient', () => {
|
describe('MoexClient', () => {
|
||||||
afterEach(() => jest.restoreAllMocks());
|
afterEach(() => jest.restoreAllMocks());
|
||||||
|
it('loads all pages of index closes without dropping a page', async () => {
|
||||||
|
const fetchMock = jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||||
|
const start = Number(new URL(input as string).searchParams.get('start'));
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
'history': {
|
||||||
|
columns: ['TRADEDATE', 'CLOSE'],
|
||||||
|
data: start === 0 ? [['2026-10-01', 5752.22]] : [['2026-10-02', 5745.61]],
|
||||||
|
},
|
||||||
|
'history.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[start, 2, 1]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
expect(await new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02')).toEqual([
|
||||||
|
{ tradeDate: '2026-10-01', close: '5752.22' },
|
||||||
|
{ tradeDate: '2026-10-02', close: '5745.61' },
|
||||||
|
]);
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a later malformed page instead of returning partial index history', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockImplementation((input) => {
|
||||||
|
const start = Number(new URL(input as string).searchParams.get('start'));
|
||||||
|
return Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
'history': {
|
||||||
|
columns: ['TRADEDATE', 'CLOSE'],
|
||||||
|
data: [[start ? '2026-10-02' : '2026-10-01', start ? null : 100]],
|
||||||
|
},
|
||||||
|
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[start, 2, 1]] },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
});
|
||||||
|
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an unsuccessful index history response', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue({ ok: false, status: 503 } as Response);
|
||||||
|
await expect(new MoexClient().indexHistory('MCFTRR', '2026-10-01', '2026-10-02'))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a timed-out index history request', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockRejectedValue(new DOMException('Timed out', 'TimeoutError'));
|
||||||
|
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects duplicate or out-of-order trading dates', async () => {
|
||||||
|
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
|
ok: true, status: 200,
|
||||||
|
json: () => Promise.resolve({
|
||||||
|
'history': { columns: ['TRADEDATE', 'CLOSE'], data: [['2026-10-02', 100], ['2026-10-01', 101]] },
|
||||||
|
'history.cursor': { columns: ['INDEX', 'TOTAL', 'PAGESIZE'], data: [[0, 2, 100]] },
|
||||||
|
}),
|
||||||
|
} as Response);
|
||||||
|
await expect(new MoexClient().indexHistory('IMOEX', '2026-10-01', '2026-10-02'))
|
||||||
|
.rejects.toThrow();
|
||||||
|
});
|
||||||
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
it('uses effective yield only for the exact ISIN and board and preserves its own price and date', async () => {
|
||||||
jest.spyOn(global, 'fetch').mockResolvedValue({
|
jest.spyOn(global, 'fetch').mockResolvedValue({
|
||||||
ok: true,
|
ok: true,
|
||||||
@@ -273,4 +340,59 @@ describe('MoexClient', () => {
|
|||||||
expect(starts).toEqual(['0', '1']);
|
expect(starts).toEqual(['0', '1']);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
it('filters non-RUB coupons and maturity out of bond events', async () => {
|
||||||
|
const pages: Record<string, unknown> = {
|
||||||
|
coupons: Response.json({
|
||||||
|
'coupons': {
|
||||||
|
columns: ['coupondate', 'value', 'faceunit'],
|
||||||
|
data: [
|
||||||
|
['2099-02-01', '2', 'SUR'],
|
||||||
|
['2099-02-02', '3', 'USD'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
'coupons.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, 2, 100]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
offers: Response.json({
|
||||||
|
'offers': {
|
||||||
|
columns: ['offerdate'],
|
||||||
|
data: [['2099-02-28']],
|
||||||
|
},
|
||||||
|
'offers.cursor': {
|
||||||
|
columns: ['INDEX', 'TOTAL', 'PAGESIZE'],
|
||||||
|
data: [[0, 1, 100]],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
description: Response.json({
|
||||||
|
description: {
|
||||||
|
columns: ['name', 'value'],
|
||||||
|
data: [
|
||||||
|
['MATDATE', '2099-02-28'],
|
||||||
|
['FACEVALUE', '500'],
|
||||||
|
['FACEUNIT', 'USD'],
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const fetchMock = jest
|
||||||
|
.spyOn(global, 'fetch')
|
||||||
|
.mockImplementation((input) => {
|
||||||
|
const url = new URL(input instanceof Request ? input.url : String(input));
|
||||||
|
const block = url.searchParams.get('iss.only')?.split(',')[0] ?? 'description';
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names of this test's page map.
|
||||||
|
const page = pages[block] ?? pages.description;
|
||||||
|
return Promise.resolve(page as Response);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await expect(new MoexClient().bondEvents('BOND')).resolves.toEqual([
|
||||||
|
{ type: 'coupon', date: '2099-02-01', value: '2', currency: 'RUB' },
|
||||||
|
{ type: 'offer', date: '2099-02-28' },
|
||||||
|
]);
|
||||||
|
} finally {
|
||||||
|
fetchMock.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
export enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||||
|
|
||||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||||
const FIRST_PAGE_OFFSET = 0;
|
const FIRST_PAGE_OFFSET = 0;
|
||||||
@@ -10,6 +10,13 @@ const EMPTY_PAGE_SIZE = 0;
|
|||||||
const MIN_CURSOR_VALUE = 0;
|
const MIN_CURSOR_VALUE = 0;
|
||||||
|
|
||||||
const FALLBACK_PAGE_SIZE = 100;
|
const FALLBACK_PAGE_SIZE = 100;
|
||||||
|
const INDEX_COLUMNS = ['TRADEDATE', 'CLOSE'];
|
||||||
|
const ISO_DATE_LENGTH = 10;
|
||||||
|
const ZERO_CLOSE = 0;
|
||||||
|
const ISO_DATE_START = 0;
|
||||||
|
const LAST_HISTORY_ROW = -1;
|
||||||
|
|
||||||
|
const isoDayPattern = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
const baseUrl = 'https://iss.moex.com/iss';
|
const baseUrl = 'https://iss.moex.com/iss';
|
||||||
const timeoutMs = 10_000;
|
const timeoutMs = 10_000;
|
||||||
@@ -28,9 +35,64 @@ export type MoexBondYield = {
|
|||||||
tradeAt: string;
|
tradeAt: string;
|
||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
export type MoexIndexClose = { close: string; tradeDate: string };
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class MoexClient {
|
export class MoexClient {
|
||||||
|
async indexHistory(
|
||||||
|
secid: 'IMOEX' | 'MCFTRR',
|
||||||
|
from: string,
|
||||||
|
till: string,
|
||||||
|
): Promise<MoexIndexClose[]> {
|
||||||
|
const path = `history/engines/stock/markets/index/securities/${secid}.json`;
|
||||||
|
const rows: MoexIndexClose[] = [];
|
||||||
|
let start = FIRST_PAGE_OFFSET;
|
||||||
|
for (;;) {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
from,
|
||||||
|
till,
|
||||||
|
'start': String(start),
|
||||||
|
'iss.meta': 'off',
|
||||||
|
'history.columns': 'TRADEDATE,CLOSE',
|
||||||
|
});
|
||||||
|
const response = await this.request(`${baseUrl}/${path}?${query}`);
|
||||||
|
if (!response) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
const block = response.history;
|
||||||
|
if (
|
||||||
|
!block || !Array.isArray(block.columns) ||
|
||||||
|
block.columns.length !== INDEX_COLUMNS.length ||
|
||||||
|
// eslint-disable-next-line security/detect-object-injection -- Offset comes from a fixed-length validated columns array.
|
||||||
|
block.columns.some((column, offset) => column !== INDEX_COLUMNS[offset])
|
||||||
|
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
const current = parseRows(block);
|
||||||
|
let previousDate = rows.at(LAST_HISTORY_ROW)?.tradeDate;
|
||||||
|
for (const row of current) {
|
||||||
|
const tradeDate = text(row.tradedate);
|
||||||
|
const close = numberText(row.close);
|
||||||
|
if (
|
||||||
|
!tradeDate || !isoDayPattern.test(tradeDate) ||
|
||||||
|
Number.isNaN(Date.parse(`${tradeDate}T00:00:00Z`)) ||
|
||||||
|
new Date(`${tradeDate}T00:00:00Z`).toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH) !== tradeDate ||
|
||||||
|
!close || Number(close) <= ZERO_CLOSE ||
|
||||||
|
(previousDate !== undefined && tradeDate <= previousDate)
|
||||||
|
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
rows.push({ tradeDate, close });
|
||||||
|
previousDate = tradeDate;
|
||||||
|
}
|
||||||
|
const cursor = parseRows(response['history.cursor']);
|
||||||
|
const index = integer(cursor[0]?.index);
|
||||||
|
const total = integer(cursor[0]?.total);
|
||||||
|
const pageSize = integer(cursor[0]?.pagesize);
|
||||||
|
if (
|
||||||
|
cursor.length !== EXPECTED_CURSOR_ROWS || index !== start ||
|
||||||
|
total === null || pageSize === null || pageSize === EMPTY_PAGE_SIZE ||
|
||||||
|
current.length !== Math.min(pageSize, total - start)
|
||||||
|
) throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
|
if (index + pageSize >= total) return rows;
|
||||||
|
start = index + pageSize;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async bondYield(
|
async bondYield(
|
||||||
secid: string,
|
secid: string,
|
||||||
isin: string,
|
isin: string,
|
||||||
@@ -95,9 +157,9 @@ export class MoexClient {
|
|||||||
for (const row of coupons) {
|
for (const row of coupons) {
|
||||||
const date = text(row.coupondate);
|
const date = text(row.coupondate);
|
||||||
const value = numberText(row.value);
|
const value = numberText(row.value);
|
||||||
const currency = currencyText(row.faceunit);
|
// RUB-only: coupons in other currencies are filtered out at this ingest boundary.
|
||||||
if (date && value && currency) {
|
if (date && value && currencyText(row.faceunit) === 'RUB') {
|
||||||
events.push({ type: 'coupon', date, value, currency });
|
events.push({ type: 'coupon', date, value, currency: 'RUB' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const row of offers) {
|
for (const row of offers) {
|
||||||
@@ -109,9 +171,8 @@ export class MoexClient {
|
|||||||
);
|
);
|
||||||
const date = values.get('MATDATE');
|
const date = values.get('MATDATE');
|
||||||
const value = numberText(values.get('FACEVALUE'));
|
const value = numberText(values.get('FACEVALUE'));
|
||||||
const currency = currencyText(values.get('FACEUNIT'));
|
if (date && value && currencyText(values.get('FACEUNIT')) === 'RUB') {
|
||||||
if (date && value && currency) {
|
events.push({ type: 'maturity', date, value, currency: 'RUB' });
|
||||||
events.push({ type: 'maturity', date, value, currency });
|
|
||||||
}
|
}
|
||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { LRUCache } from 'lru-cache';
|
|||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||||
|
|
||||||
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
export enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||||
|
|
||||||
const EXPECTED_REGISTRY_TABLES = 1;
|
const EXPECTED_REGISTRY_TABLES = 1;
|
||||||
const RATING_COLUMN = 2;
|
const RATING_COLUMN = 2;
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { Decimal } from '../../common/decimal';
|
import type { Money } from '../../common/money.types';
|
||||||
|
|
||||||
|
import { Decimal, formatDecimal } from '../../common/decimal';
|
||||||
|
|
||||||
const MISSING_PROTO_COMPONENT = 0;
|
const MISSING_PROTO_COMPONENT = 0;
|
||||||
|
|
||||||
@@ -6,6 +8,12 @@ const NANO = 1_000_000_000n;
|
|||||||
|
|
||||||
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
export type ProtoDecimal = { nano?: number | string; units?: number | string };
|
||||||
|
|
||||||
|
export function rubMoney(value?: ProtoDecimal & { currency?: string }): Money | null {
|
||||||
|
return value?.currency?.trim().toUpperCase() === 'RUB'
|
||||||
|
? { currency: 'RUB', value: formatDecimal(decimal(value)) }
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
export function decimal(value: ProtoDecimal): Decimal {
|
export function decimal(value: ProtoDecimal): Decimal {
|
||||||
return {
|
return {
|
||||||
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
value: BigInt(value.units ?? MISSING_PROTO_COMPONENT) * NANO + BigInt(value.nano ?? MISSING_PROTO_COMPONENT),
|
||||||
|
|||||||
@@ -5,13 +5,15 @@ import { isDeepStrictEqual } from 'node:util';
|
|||||||
import type {
|
import type {
|
||||||
TbankInstrumentLookup as MetadataLookupRow,
|
TbankInstrumentLookup as MetadataLookupRow,
|
||||||
TbankInstrument as MetadataRow,
|
TbankInstrument as MetadataRow,
|
||||||
Prisma,
|
} from '#generated/prisma/client';
|
||||||
} from '../../../generated/prisma/client';
|
|
||||||
import type { TbankSyncAttempt } from './tbank-access.service';
|
|
||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { toJson } from '../../prisma/to-json';
|
||||||
|
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -119,11 +121,8 @@ export class InstrumentMetadataRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankConnection.updateMany({
|
const valid = await lockConnectionGeneration(tx, attemptOwner);
|
||||||
where: { ...attemptOwner, invalidToken: false },
|
if (!valid) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!valid.count) return null;
|
|
||||||
const scope = {
|
const scope = {
|
||||||
...attemptOwner,
|
...attemptOwner,
|
||||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||||
@@ -131,11 +130,7 @@ export class InstrumentMetadataRepository {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -171,15 +166,11 @@ export class InstrumentMetadataRepository {
|
|||||||
|
|
||||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const aliases = new Map(
|
const aliases = new Map(
|
||||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||||
);
|
);
|
||||||
@@ -216,9 +207,7 @@ export class InstrumentMetadataRepository {
|
|||||||
const previous = await tx.tbankInstrument.findUnique({
|
const previous = await tx.tbankInstrument.findUnique({
|
||||||
where: { provider_uid: key },
|
where: { provider_uid: key },
|
||||||
});
|
});
|
||||||
const payload = JSON.parse(
|
const payload = toJson(instrument);
|
||||||
JSON.stringify(instrument),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
const data = {
|
const data = {
|
||||||
metadata: payload,
|
metadata: payload,
|
||||||
figi:
|
figi:
|
||||||
@@ -268,18 +257,17 @@ export class InstrumentMetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await claimSyncWork(
|
||||||
where: {
|
this.prisma,
|
||||||
...attempt,
|
attempt,
|
||||||
state: 'running',
|
{
|
||||||
...(success ? { connection: { invalidToken: false } } : {}),
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
{ withoutConnectionFence: !success },
|
||||||
|
);
|
||||||
if (success) {
|
if (success) {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await this.prisma.tbankSyncWork.updateMany({
|
await this.prisma.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
|||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
import { TbankErrorCode } from './tbank-error-code';
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
|
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||||
@@ -25,6 +26,22 @@ export type TbankSyncAttempt = {
|
|||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Join rule shared by sync reservations: the same process joins its own running attempt
|
||||||
|
// only while that attempt is still active in memory. Set and Map both satisfy `has`.
|
||||||
|
export function isJoinableRunningAttempt<
|
||||||
|
T extends { attemptId: string; processRunId: string; state: string },
|
||||||
|
>(
|
||||||
|
previous: null | T,
|
||||||
|
processRunId: string,
|
||||||
|
active: { has(attemptId: string): boolean },
|
||||||
|
): previous is T {
|
||||||
|
return (
|
||||||
|
previous?.state === 'running' &&
|
||||||
|
previous.processRunId === processRunId &&
|
||||||
|
active.has(previous.attemptId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TbankAccessService {
|
export class TbankAccessService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -40,11 +57,8 @@ export class TbankAccessService {
|
|||||||
force: boolean,
|
force: boolean,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, { userId, revision });
|
||||||
where: { userId, revision, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
});
|
});
|
||||||
@@ -62,11 +76,7 @@ export class TbankAccessService {
|
|||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, processRunId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === processRunId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -98,15 +108,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(this.prisma, attempt, {
|
||||||
where: {
|
state: 'failed',
|
||||||
...attempt,
|
errorCode: TbankErrorCode.Unavailable,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||||
@@ -151,15 +157,8 @@ export class TbankAccessService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// Acquire the write fence before reading membership. Replacement and
|
// Acquire the write fence before reading membership. Replacement and
|
||||||
// attempt supersession cannot interleave with the publication below.
|
// attempt supersession cannot interleave with the publication below.
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||||
where: {
|
if (!claimed) return false;
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { updatedAt: new Date() },
|
|
||||||
});
|
|
||||||
if (!claimed.count) return false;
|
|
||||||
const { userId, revision } = attempt;
|
const { userId, revision } = attempt;
|
||||||
const previous = await tx.tbankAccount.findMany({
|
const previous = await tx.tbankAccount.findMany({
|
||||||
where: { userId, revision },
|
where: { userId, revision },
|
||||||
@@ -232,11 +231,11 @@ export class TbankAccessService {
|
|||||||
|
|
||||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: { ...attempt, state: 'running' },
|
state: 'failed',
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
errorCode: TbankErrorCode.InvalidToken,
|
||||||
});
|
}, { withoutConnectionFence: true });
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
await tx.tbankConnection.update({
|
await tx.tbankConnection.update({
|
||||||
where: {
|
where: {
|
||||||
userId_revision: {
|
userId_revision: {
|
||||||
|
|||||||
@@ -49,11 +49,17 @@ export enum TbankOperationType {
|
|||||||
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||||
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
InpMulti = 'OPERATION_TYPE_INP_MULTI',
|
||||||
Input = 'OPERATION_TYPE_INPUT',
|
Input = 'OPERATION_TYPE_INPUT',
|
||||||
|
InputAcquiring = 'OPERATION_TYPE_INPUT_ACQUIRING',
|
||||||
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
InputSwift = 'OPERATION_TYPE_INPUT_SWIFT',
|
||||||
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
OutMulti = 'OPERATION_TYPE_OUT_MULTI',
|
||||||
Output = 'OPERATION_TYPE_OUTPUT',
|
Output = 'OPERATION_TYPE_OUTPUT',
|
||||||
|
OutputAcquiring = 'OPERATION_TYPE_OUTPUT_ACQUIRING',
|
||||||
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
OutputSwift = 'OPERATION_TYPE_OUTPUT_SWIFT',
|
||||||
Sell = 'OPERATION_TYPE_SELL',
|
Sell = 'OPERATION_TYPE_SELL',
|
||||||
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||||
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import type { Prisma } from '#generated/prisma/client';
|
||||||
|
|
||||||
|
import type { TbankSyncAttempt } from './tbank-access.service';
|
||||||
|
|
||||||
|
const NO_UPDATED_ROWS = 0;
|
||||||
|
|
||||||
|
// Shared owners for the tbank-sync DB claim/lock protocol.
|
||||||
|
// Both helpers are CAS fences: they run inside the caller's transaction and
|
||||||
|
// keep the exact where/data shapes they replaced. Callers keep deciding
|
||||||
|
// success from the claim count exactly as before.
|
||||||
|
|
||||||
|
// Claims the active TbankSyncWork row: only the still-running attempt of a
|
||||||
|
// valid connection may transition. `data` is the state transition to apply.
|
||||||
|
export type ClaimOptions = {
|
||||||
|
// Overrides the default 'running' fence (e.g. an initiator row also
|
||||||
|
// accepts 'complete').
|
||||||
|
state: Prisma.TbankSyncWorkWhereInput['state'] | undefined;
|
||||||
|
// true drops the `connection: { invalidToken: false }` guard for fences
|
||||||
|
// that must also fire on an invalidated connection.
|
||||||
|
withoutConnectionFence: boolean | undefined;
|
||||||
|
};
|
||||||
|
export async function claimSyncWork(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
data: Prisma.TbankSyncWorkUpdateManyMutationInput,
|
||||||
|
options: Partial<ClaimOptions> = {},
|
||||||
|
): Promise<number> {
|
||||||
|
const result = await tx.tbankSyncWork.updateMany({
|
||||||
|
where: {
|
||||||
|
...attempt,
|
||||||
|
state: options.state ?? 'running',
|
||||||
|
...(options.withoutConnectionFence
|
||||||
|
? {}
|
||||||
|
: { connection: { invalidToken: false } }),
|
||||||
|
},
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return result.count;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Locks the connection generation before reserving or publishing work: the
|
||||||
|
// row must still be the current revision with a valid token. `extra`
|
||||||
|
// (e.g. account membership) is merged into the where clause.
|
||||||
|
export async function lockConnectionGeneration(
|
||||||
|
tx: Prisma.TransactionClient,
|
||||||
|
owner: { revision: string; userId: string },
|
||||||
|
extra: Prisma.TbankConnectionWhereInput = {},
|
||||||
|
): Promise<boolean> {
|
||||||
|
const result = await tx.tbankConnection.updateMany({
|
||||||
|
where: { ...owner, ...extra, invalidToken: false },
|
||||||
|
data: { membershipRevision: { increment: 0 } },
|
||||||
|
});
|
||||||
|
return result.count > NO_UPDATED_ROWS;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service';
|
|||||||
export { metadataKey } from './instrument-metadata.service';
|
export { metadataKey } from './instrument-metadata.service';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.service';
|
export type { MetadataIdentity } from './instrument-metadata.service';
|
||||||
export {
|
export {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
|||||||
@@ -236,6 +236,16 @@ describe('TbankClient metadata retries', () => {
|
|||||||
jest.useRealTimers();
|
jest.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['bondBy', (client: TbankClient, optIn: boolean) => client.getBond('token', 'uid', undefined, optIn)],
|
||||||
|
['etfBy', (client: TbankClient, optIn: boolean) => client.getEtf('token', 'uid', undefined, optIn)],
|
||||||
|
['getAssetBy', (client: TbankClient, optIn: boolean) => optIn ? client.getAssetBrand('token', 'uid') : client.getAssetDescription('token', 'uid')],
|
||||||
|
])('%s reports NOT_FOUND only for classification lookups', async (_method, invoke) => {
|
||||||
|
requestRpc.mockRejectedValue({ code: status.NOT_FOUND });
|
||||||
|
await expect(invoke(client, false)).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
await expect(invoke(client, true)).rejects.toBeInstanceOf(NotFoundException);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
status.UNAVAILABLE,
|
status.UNAVAILABLE,
|
||||||
status.DEADLINE_EXCEEDED,
|
status.DEADLINE_EXCEEDED,
|
||||||
|
|||||||
@@ -375,18 +375,29 @@ export class TbankClient {
|
|||||||
).then((response) => response.instruments ?? []);
|
).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 }>(
|
return this.call<{ instrument?: TbankBond }>(
|
||||||
token,
|
token,
|
||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
'bondBy',
|
'bondBy',
|
||||||
{
|
{
|
||||||
idType: InstrumentIdType.Uid,
|
idType,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
|
notFound,
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getEtf(
|
||||||
|
token: string,
|
||||||
|
id: string,
|
||||||
|
idType = InstrumentIdType.Uid,
|
||||||
|
notFound = false,
|
||||||
|
): Promise<null | Pick<TbankShare, 'assetUid' | 'figi' | 'sector' | 'uid'>> {
|
||||||
|
return this.call<{ instrument?: TbankShare }>(token, this.sparseInstruments, 'etfBy', { idType, id }, notFound)
|
||||||
|
.then((response) => response.instrument ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
getBondCoupons(
|
getBondCoupons(
|
||||||
token: string,
|
token: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
@@ -405,18 +416,25 @@ export class TbankClient {
|
|||||||
).then((response) => response.events ?? []);
|
).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 }>(
|
return this.call<{ instrument?: TbankShare }>(
|
||||||
token,
|
token,
|
||||||
this.instruments,
|
this.instruments,
|
||||||
'shareBy',
|
'shareBy',
|
||||||
{
|
{
|
||||||
idType: InstrumentIdType.Uid,
|
idType,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
|
notFound,
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getAssetBrand(token: string, assetUid: string): Promise<null | { assetUid?: string; brand?: { name?: string; uid?: string } }> {
|
||||||
|
return this.call<{ asset?: { brand?: { name?: string; uid?: string }; uid?: string } }>(
|
||||||
|
token, this.instruments, 'getAssetBy', { id: assetUid }, true,
|
||||||
|
).then((response) => response.asset ? { assetUid: response.asset.uid, brand: response.asset.brand } : null);
|
||||||
|
}
|
||||||
|
|
||||||
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
getAssetDescription(token: string, assetUid: string): Promise<null | string> {
|
||||||
return this.call<{ asset?: { description?: string } }>(
|
return this.call<{ asset?: { description?: string } }>(
|
||||||
token,
|
token,
|
||||||
@@ -514,6 +532,7 @@ export class TbankClient {
|
|||||||
client: TbankClientService,
|
client: TbankClientService,
|
||||||
method: string,
|
method: string,
|
||||||
request: object,
|
request: object,
|
||||||
|
notFound = false,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const metadataCall = method === 'getInstrumentBy';
|
const metadataCall = method === 'getInstrumentBy';
|
||||||
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
for (let attempts = FIRST_ATTEMPT; attempts <= RPC_MAX_ATTEMPTS; attempts += ATTEMPT_INCREMENT) {
|
||||||
@@ -576,7 +595,7 @@ export class TbankClient {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
(method === 'shareBy' || metadataCall)
|
(notFound || method === 'shareBy' || metadataCall)
|
||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
|||||||
import { JwtService } from '@nestjs/jwt';
|
import { JwtService } from '@nestjs/jwt';
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
|
|
||||||
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
|
|
||||||
jest.mock('bcrypt', () => ({
|
jest.mock('bcrypt', () => ({
|
||||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||||
compare: jest.fn(() => Promise.resolve(true)),
|
compare: jest.fn(() => Promise.resolve(true)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class PrismaService {},
|
PrismaService: class PrismaService {},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -51,7 +53,7 @@ describe('AuthService', () => {
|
|||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const service = new AuthService(prisma as never, jwt);
|
const service = new AuthService(new AuthRepository(prisma as never), jwt);
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
|||||||
@@ -7,15 +7,14 @@ import { JwtService } from '@nestjs/jwt';
|
|||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
import { 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 { HttpErrorCode } from '#src/common/error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
|
||||||
|
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||||
import { AuthErrorCode } from './auth-error-code';
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
|
||||||
|
|
||||||
const PASSWORD_HASH_ROUNDS = 12;
|
const PASSWORD_HASH_ROUNDS = 12;
|
||||||
export const REFRESH_TTL_MS = 604_800_000;
|
export const REFRESH_TTL_MS = 604_800_000;
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ const DUMMY_PASSWORD_HASH =
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class AuthService {
|
export class AuthService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
private readonly repository: AuthRepository,
|
||||||
private readonly jwt: JwtService,
|
private readonly jwt: JwtService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -36,28 +35,19 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const existing = await this.prisma.user.findUnique({
|
const existing = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User;
|
const user = await this.repository.createUser({
|
||||||
try {
|
|
||||||
user = await this.prisma.user.create({
|
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
email,
|
email,
|
||||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||||
},
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
if (!user) {
|
||||||
if (isUniqueConstraintError(error)) {
|
|
||||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,9 +55,7 @@ export class AuthService {
|
|||||||
email: string,
|
email: string,
|
||||||
password: string,
|
password: string,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const user = await this.prisma.user.findUnique({
|
const user = await this.repository.findUserByEmail(email);
|
||||||
where: { email },
|
|
||||||
});
|
|
||||||
const valid = await bcrypt.compare(
|
const valid = await bcrypt.compare(
|
||||||
password,
|
password,
|
||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
@@ -92,10 +80,7 @@ export class AuthService {
|
|||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSessionWithUser(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
include: { user: true },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
!session ||
|
!session ||
|
||||||
session.userId !== payload.sub ||
|
session.userId !== payload.sub ||
|
||||||
@@ -103,7 +88,7 @@ export class AuthService {
|
|||||||
session.expiresAt <= new Date()
|
session.expiresAt <= new Date()
|
||||||
) {
|
) {
|
||||||
if (session && !session.revokedAt) {
|
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 });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
@@ -124,18 +109,13 @@ export class AuthService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') return;
|
if (payload.type !== 'refresh') return;
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.repository.findSession(payload.sid);
|
||||||
where: { id: payload.sid },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
session &&
|
session &&
|
||||||
!session.revokedAt &&
|
!session.revokedAt &&
|
||||||
session.refreshTokenHash === this.hash(token)
|
session.refreshTokenHash === this.hash(token)
|
||||||
) {
|
) {
|
||||||
await this.prisma.session.update({
|
await this.repository.revokeSession(session.id);
|
||||||
where: { id: session.id },
|
|
||||||
data: { revokedAt: new Date() },
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +124,7 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
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 });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
@@ -152,14 +132,7 @@ export class AuthService {
|
|||||||
private async createSession(
|
private async createSession(
|
||||||
user: User,
|
user: User,
|
||||||
): Promise<AuthenticationResult> {
|
): Promise<AuthenticationResult> {
|
||||||
const session = await this.prisma.session.create({
|
const session = await this.repository.createSession(user.id, randomUUID());
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId: user.id,
|
|
||||||
refreshTokenHash: '',
|
|
||||||
expiresAt: new Date(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return this.rotateSession({ ...session, user });
|
return this.rotateSession({ ...session, user });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,17 +153,9 @@ export class AuthService {
|
|||||||
refreshTokenHash: this.hash(refreshToken),
|
refreshTokenHash: this.hash(refreshToken),
|
||||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||||
};
|
};
|
||||||
if (expectedRefreshTokenHash) {
|
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
|
||||||
const updated = await this.prisma.session.updateMany({
|
|
||||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
|
||||||
data,
|
|
||||||
});
|
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
|
||||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
user: this.toUser(session.user),
|
user: this.toUser(session.user),
|
||||||
accessToken: await this.jwt.signAsync(
|
accessToken: await this.jwt.signAsync(
|
||||||
@@ -205,12 +170,3 @@ export class AuthService {
|
|||||||
return createHash('sha256').update(token).digest('hex');
|
return createHash('sha256').update(token).digest('hex');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isUniqueConstraintError(error: unknown): boolean {
|
|
||||||
return (
|
|
||||||
typeof error === 'object' &&
|
|
||||||
error !== null &&
|
|
||||||
'code' in error &&
|
|
||||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
|||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
import { AuthService } from './application/auth.service';
|
import { AuthService } from './application/auth.service';
|
||||||
|
import { AuthRepository } from './infrastructure/auth.repository';
|
||||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||||
import { AuthController } from './presentation/auth.controller';
|
import { AuthController } from './presentation/auth.controller';
|
||||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||||
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
|||||||
],
|
],
|
||||||
exports: [JwtAuthGuard],
|
exports: [JwtAuthGuard],
|
||||||
controllers: [AuthController],
|
controllers: [AuthController],
|
||||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||||
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
|
|
||||||
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AuthRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
findUserByEmail(email: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { email } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async createUser(data: { email: string; id: string; passwordHash: string }) {
|
||||||
|
try {
|
||||||
|
return await this.prisma.user.create({ data });
|
||||||
|
} catch (error) {
|
||||||
|
if (isUniqueConstraintError(error)) return null;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
findUserById(id: string) {
|
||||||
|
return this.prisma.user.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
findSessionWithUser(id: string) {
|
||||||
|
return this.prisma.session.findUnique({
|
||||||
|
where: { id },
|
||||||
|
include: { user: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
findSession(id: string) {
|
||||||
|
return this.prisma.session.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSession(id: string) {
|
||||||
|
return this.prisma.session.delete({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
revokeSession(id: string) {
|
||||||
|
return this.prisma.session.update({
|
||||||
|
where: { id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
createSession(userId: string, id: string) {
|
||||||
|
return this.prisma.session.create({
|
||||||
|
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async rotateSession(
|
||||||
|
id: string,
|
||||||
|
data: { expiresAt: Date; refreshTokenHash: string },
|
||||||
|
expectedRefreshTokenHash?: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (expectedRefreshTokenHash) {
|
||||||
|
const updated = await this.prisma.session.updateMany({
|
||||||
|
where: { id, refreshTokenHash: expectedRefreshTokenHash },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
return updated.count === EXPECTED_ROTATED_SESSIONS;
|
||||||
|
}
|
||||||
|
await this.prisma.session.update({ where: { id }, data });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUniqueConstraintError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
|
|||||||
import { PassportStrategy } from '@nestjs/passport';
|
import { PassportStrategy } from '@nestjs/passport';
|
||||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||||
|
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '#src/common/error-code';
|
||||||
import { Environment } from '../../../config/env.validation';
|
import { Environment } from '#src/config/env.validation';
|
||||||
|
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ import {
|
|||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { HttpErrorCode } from '../../../common/error-code';
|
import { HttpErrorCode } from '#src/common/error-code';
|
||||||
|
|
||||||
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||||
import { LoginDto, RegisterDto } from './auth.dto';
|
import { LoginDto, RegisterDto } from './auth.dto';
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
MAX_CARD_NAME_LENGTH,
|
MAX_CARD_NAME_LENGTH,
|
||||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
import type { BankCard, Prisma } from '#generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { bankBalance } from '../domain/bank-card.calculations';
|
import { bankBalance } from '../domain/bank-card.calculations';
|
||||||
import { rubMoney } from '../domain/rub-money';
|
import { rubMoney } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
@@ -20,24 +20,13 @@ import {
|
|||||||
missing,
|
missing,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CARDS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsService {
|
export class BankCardsService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
private async cardView(tx: Tx, card: BankCard): Promise<BankCardDto> {
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
const rows = await this.repository.cardOperations(tx, card);
|
||||||
where: {
|
|
||||||
userId: card.userId,
|
|
||||||
deletedAt: null,
|
|
||||||
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
id: card.id,
|
id: card.id,
|
||||||
name: card.name,
|
name: card.name,
|
||||||
@@ -53,20 +42,15 @@ export class BankCardsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
list(userId: string, includeArchived = false) {
|
list(userId: string, includeArchived = false) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.repository.read(async (tx) =>
|
||||||
Promise.all(
|
Promise.all((await this.repository.cards(tx, userId, includeArchived)).map(
|
||||||
(
|
(card) => this.cardView(tx, card),
|
||||||
await tx.bankCard.findMany({
|
)),
|
||||||
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
|
||||||
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
|
||||||
})
|
|
||||||
).map((card) => this.cardView(tx, card)),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
get(userId: string, id: string) {
|
get(userId: string, id: string) {
|
||||||
return this.prisma.$transaction(async (tx) =>
|
return this.repository.read(async (tx) =>
|
||||||
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
this.cardView(tx, await this.repository.card(tx, userId, id)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -75,7 +59,7 @@ export class BankCardsService {
|
|||||||
const input = cardInput(dto),
|
const input = cardInput(dto),
|
||||||
hash = fingerprint(input);
|
hash = fingerprint(input);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const existing = await tx.bankCard.findUnique({ where: { id: dto.id } });
|
const existing = await this.repository.cardById(tx, dto.id);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
@@ -88,8 +72,11 @@ export class BankCardsService {
|
|||||||
await this.repository.seed(tx, userId);
|
await this.repository.seed(tx, userId);
|
||||||
return this.cardView(
|
return this.cardView(
|
||||||
tx,
|
tx,
|
||||||
await tx.bankCard.create({
|
await this.repository.createCard(tx, {
|
||||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
...input,
|
||||||
|
id: dto.id,
|
||||||
|
userId,
|
||||||
|
createFingerprint: hash,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -122,20 +109,16 @@ export class BankCardsService {
|
|||||||
}
|
}
|
||||||
currentDate(input.openingDate);
|
currentDate(input.openingDate);
|
||||||
if (
|
if (
|
||||||
await tx.bankCardOperation.findFirst({
|
await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate)
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
deletedAt: null,
|
|
||||||
date: { lt: input.openingDate },
|
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
},
|
|
||||||
})
|
|
||||||
) {
|
) {
|
||||||
fail(BankErrorCode.OpeningDateConflict);
|
fail(BankErrorCode.OpeningDateConflict);
|
||||||
}
|
}
|
||||||
const changed = await tx.bankCard.updateMany({
|
const updated = await this.repository.updateCard(
|
||||||
where: { id, userId, version: dto.version },
|
tx,
|
||||||
data: {
|
userId,
|
||||||
|
id,
|
||||||
|
dto.version,
|
||||||
|
{
|
||||||
...input,
|
...input,
|
||||||
paymentSystem: input.paymentSystem ?? null,
|
paymentSystem: input.paymentSystem ?? null,
|
||||||
version: { increment: 1 },
|
version: { increment: 1 },
|
||||||
@@ -147,9 +130,8 @@ export class BankCardsService {
|
|||||||
: null,
|
: null,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
return this.cardView(tx, updated);
|
||||||
return this.cardView(tx, await this.repository.card(tx, userId, id));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
import { GoneException, Injectable } from '@nestjs/common';
|
import { GoneException, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import {
|
import type {
|
||||||
BankCardOperation,
|
BankCardOperation,
|
||||||
Prisma,
|
Prisma,
|
||||||
} from '../../../../../generated/prisma/client';
|
} from '#generated/prisma/client';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
import {
|
||||||
|
operationFieldConflict,
|
||||||
|
operationKindChanged,
|
||||||
|
} from '../domain/bank-operation.rules';
|
||||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import {
|
import {
|
||||||
@@ -28,117 +32,22 @@ import {
|
|||||||
periodBounds,
|
periodBounds,
|
||||||
} from './bank-card-input';
|
} from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ROWS = 1;
|
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankOperationsService {
|
export class BankOperationsService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
private async operationView(
|
private async operationView(
|
||||||
tx: Tx,
|
tx: Tx,
|
||||||
row: BankCardOperation,
|
row: BankCardOperation,
|
||||||
): Promise<BankOperationDto> {
|
): Promise<BankOperationDto> {
|
||||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
return (await this.pageViews(tx, [row]))[0];
|
||||||
const target = row.targetCardId
|
|
||||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
|
||||||
: null;
|
|
||||||
const source = row.originalExpenseId
|
|
||||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
|
||||||
: null;
|
|
||||||
const categoryId = source?.categoryId ?? row.categoryId;
|
|
||||||
const category = categoryId
|
|
||||||
? await tx.expenseCategory.findFirst({
|
|
||||||
where: { id: categoryId, userId: row.userId },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
const refunds =
|
|
||||||
row.kind === BankOperationKind.Expense || source
|
|
||||||
? await tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId: row.userId,
|
|
||||||
originalExpenseId: source?.id ?? row.id,
|
|
||||||
deletedAt: null,
|
|
||||||
...(source ? { id: { not: row.id } } : {}),
|
|
||||||
},
|
|
||||||
})
|
|
||||||
: [];
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
kind: row.kind as BankOperationDto['kind'],
|
|
||||||
cardId: row.cardId,
|
|
||||||
cardName: card.name,
|
|
||||||
targetCardId: row.targetCardId,
|
|
||||||
targetCardName: target?.name ?? null,
|
|
||||||
categoryId,
|
|
||||||
categoryName: category?.name ?? null,
|
|
||||||
originalExpenseId: row.originalExpenseId,
|
|
||||||
amount: rubMoney(row.amountKopecks),
|
|
||||||
availableRefund:
|
|
||||||
row.kind === BankOperationKind.Expense || source
|
|
||||||
? rubMoney(
|
|
||||||
(source?.amountKopecks ?? row.amountKopecks) -
|
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
date: row.date,
|
|
||||||
description: row.description,
|
|
||||||
version: row.version,
|
|
||||||
createdAt: row.createdAt.toISOString(),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||||
const userId = rows[0]?.userId;
|
const userId = rows[0]?.userId;
|
||||||
if (!userId) return [];
|
if (!userId) return [];
|
||||||
const cardIds = new Set(
|
const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows);
|
||||||
rows.flatMap((row) =>
|
|
||||||
[row.cardId, row.targetCardId].filter(
|
|
||||||
(id): id is string => id !== null,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const sourceIds = new Set(
|
|
||||||
rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])),
|
|
||||||
);
|
|
||||||
const [cards, sources] = await Promise.all([
|
|
||||||
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
|
||||||
tx.bankCardOperation.findMany({
|
|
||||||
where: { userId, id: { in: [...sourceIds] } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const categoryIds = new Set(
|
|
||||||
rows
|
|
||||||
.map(
|
|
||||||
(row) =>
|
|
||||||
sources.find((source) => source.id === row.originalExpenseId)
|
|
||||||
?.categoryId ?? row.categoryId,
|
|
||||||
)
|
|
||||||
.filter((id): id is string => id !== null),
|
|
||||||
);
|
|
||||||
const expenseIds = new Set(
|
|
||||||
rows.flatMap((row) => {
|
|
||||||
const source = sources.find((item) => item.id === row.originalExpenseId);
|
|
||||||
return row.kind === BankOperationKind.Expense || source
|
|
||||||
? [source?.id ?? row.id]
|
|
||||||
: [];
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const [categories, refunds] = await Promise.all([
|
|
||||||
tx.expenseCategory.findMany({
|
|
||||||
where: { userId, id: { in: [...categoryIds] } },
|
|
||||||
}),
|
|
||||||
tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
originalExpenseId: { in: [...expenseIds] },
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
const cardsById = new Map(cards.map((card) => [card.id, card]));
|
||||||
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
const sourcesById = new Map(sources.map((source) => [source.id, source]));
|
||||||
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
const categoriesById = new Map(categories.map((category) => [category.id, category]));
|
||||||
@@ -207,49 +116,28 @@ export class BankOperationsService {
|
|||||||
fail(BankErrorCode.InvalidDate);
|
fail(BankErrorCode.InvalidDate);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (
|
if (old && operationKindChanged(old.kind, input.kind,
|
||||||
old &&
|
old.originalExpenseId, input.originalExpenseId)) {
|
||||||
(old.kind !== input.kind ||
|
|
||||||
old.originalExpenseId !== input.originalExpenseId)
|
|
||||||
) {
|
|
||||||
fail(BankErrorCode.ImmutableOperation);
|
fail(BankErrorCode.ImmutableOperation);
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Transfer) {
|
if (operationFieldConflict(input)) fail();
|
||||||
if (
|
|
||||||
!input.targetCardId ||
|
|
||||||
input.targetCardId === input.cardId ||
|
|
||||||
input.categoryId ||
|
|
||||||
input.originalExpenseId
|
|
||||||
) {
|
|
||||||
fail();
|
|
||||||
}
|
|
||||||
} else if (input.targetCardId) fail();
|
|
||||||
if (input.kind === BankOperationKind.Expense) {
|
if (input.kind === BankOperationKind.Expense) {
|
||||||
if (input.originalExpenseId) fail();
|
|
||||||
if (!input.categoryId) {
|
if (!input.categoryId) {
|
||||||
const category = await tx.expenseCategory.findUnique({
|
const category = await this.repository.defaultCategory(tx, userId);
|
||||||
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
|
||||||
});
|
|
||||||
if (!category) missing();
|
if (!category) missing();
|
||||||
input.categoryId = category.id;
|
input.categoryId = category.id;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
!(await tx.expenseCategory.findFirst({
|
!(await this.repository.category(tx, userId, input.categoryId))
|
||||||
where: { userId, id: input.categoryId },
|
|
||||||
}))
|
|
||||||
) {
|
) {
|
||||||
missing();
|
missing();
|
||||||
}
|
}
|
||||||
} else if (input.categoryId) fail();
|
|
||||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
|
||||||
fail();
|
|
||||||
}
|
}
|
||||||
if (input.kind === BankOperationKind.Refund) {
|
if (input.kind === BankOperationKind.Refund) {
|
||||||
if (!input.originalExpenseId) fail();
|
|
||||||
const source = await this.repository.operation(
|
const source = await this.repository.operation(
|
||||||
tx,
|
tx,
|
||||||
userId,
|
userId,
|
||||||
input.originalExpenseId,
|
input.originalExpenseId as string,
|
||||||
);
|
);
|
||||||
if (
|
if (
|
||||||
source.deletedAt ||
|
source.deletedAt ||
|
||||||
@@ -258,29 +146,11 @@ export class BankOperationsService {
|
|||||||
) {
|
) {
|
||||||
fail(BankErrorCode.InvalidRefund);
|
fail(BankErrorCode.InvalidRefund);
|
||||||
}
|
}
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id: source.id, userId, version: source.version, deletedAt: null },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ version: { increment: 1 } },
|
||||||
where: {
|
);
|
||||||
id: source.id,
|
const refunds = await this.repository.refunds(tx, userId, source.id, old?.id);
|
||||||
userId,
|
|
||||||
version: source.version,
|
|
||||||
deletedAt: null,
|
|
||||||
},
|
|
||||||
data: { version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
|
||||||
where: {
|
|
||||||
userId,
|
|
||||||
originalExpenseId: source.id,
|
|
||||||
deletedAt: null,
|
|
||||||
...(old ? { id: { not: old.id } } : {}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
if (input.date < source.date) fail(BankErrorCode.InvalidDate);
|
||||||
if (
|
if (
|
||||||
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) +
|
||||||
@@ -291,9 +161,7 @@ export class BankOperationsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (old?.kind === BankOperationKind.Expense) {
|
if (old?.kind === BankOperationKind.Expense) {
|
||||||
const refunds = await tx.bankCardOperation.findMany({
|
const refunds = await this.repository.refunds(tx, userId, old.id);
|
||||||
where: { userId, originalExpenseId: old.id, deletedAt: null },
|
|
||||||
});
|
|
||||||
if (
|
if (
|
||||||
refunds.length &&
|
refunds.length &&
|
||||||
(input.cardId !== old.cardId ||
|
(input.cardId !== old.cardId ||
|
||||||
@@ -310,9 +178,7 @@ export class BankOperationsService {
|
|||||||
const normalized = operationInput(dto),
|
const normalized = operationInput(dto),
|
||||||
hash = fingerprint(normalized);
|
hash = fingerprint(normalized);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.write(async (tx) => {
|
||||||
const existing = await tx.bankCardOperation.findUnique({
|
const existing = await this.repository.operationById(tx, dto.id);
|
||||||
where: { id: dto.id },
|
|
||||||
});
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (existing.userId !== userId) missing();
|
if (existing.userId !== userId) missing();
|
||||||
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
// eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret.
|
||||||
@@ -328,15 +194,15 @@ export class BankOperationsService {
|
|||||||
await this.validateOperation(tx, userId, input);
|
await this.validateOperation(tx, userId, input);
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await tx.bankCardOperation.create({
|
await this.repository.createOperation(tx, {
|
||||||
data: { ...input, id: dto.id, userId, createFingerprint: hash },
|
...input, id: dto.id, userId, createFingerprint: hash,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getOperation(userId: string, id: string) {
|
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);
|
const row = await this.repository.operation(tx, userId, id);
|
||||||
if (row.deletedAt) missing();
|
if (row.deletedAt) missing();
|
||||||
return this.operationView(tx, row);
|
return this.operationView(tx, row);
|
||||||
@@ -361,16 +227,10 @@ export class BankOperationsService {
|
|||||||
description: dto.description ?? old.description ?? undefined,
|
description: dto.description ?? old.description ?? undefined,
|
||||||
});
|
});
|
||||||
await this.validateOperation(tx, userId, input, old);
|
await this.validateOperation(tx, userId, input, old);
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id, userId, version: dto.version, deletedAt: null },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ ...input, version: { increment: 1 } },
|
||||||
where: { id, userId, version: dto.version, deletedAt: null },
|
);
|
||||||
data: { ...input, version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
return this.operationView(
|
return this.operationView(
|
||||||
tx,
|
tx,
|
||||||
await this.repository.operation(tx, userId, id),
|
await this.repository.operation(tx, userId, id),
|
||||||
@@ -389,9 +249,7 @@ export class BankOperationsService {
|
|||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
row.kind === BankOperationKind.Expense &&
|
row.kind === BankOperationKind.Expense &&
|
||||||
(await tx.bankCardOperation.findFirst({
|
(await this.repository.dependentRefund(tx, userId, id))
|
||||||
where: { userId, originalExpenseId: id, deletedAt: null },
|
|
||||||
}))
|
|
||||||
) {
|
) {
|
||||||
conflict(BankErrorCode.DependentRefunds);
|
conflict(BankErrorCode.DependentRefunds);
|
||||||
}
|
}
|
||||||
@@ -401,27 +259,15 @@ export class BankOperationsService {
|
|||||||
userId,
|
userId,
|
||||||
row.originalExpenseId,
|
row.originalExpenseId,
|
||||||
);
|
);
|
||||||
if (
|
await this.repository.updateOperation(tx,
|
||||||
(
|
{ id: source.id, userId, version: source.version },
|
||||||
await tx.bankCardOperation.updateMany({
|
{ version: { increment: 1 } },
|
||||||
where: { id: source.id, userId, version: source.version },
|
);
|
||||||
data: { version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
await tx.bankCardOperation.updateMany({
|
|
||||||
where: { id, userId, version, deletedAt: null },
|
|
||||||
data: { deletedAt: new Date(), version: { increment: 1 } },
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_ROWS
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
}
|
||||||
|
await this.repository.updateOperation(tx,
|
||||||
|
{ id, userId, version, deletedAt: null },
|
||||||
|
{ deletedAt: new Date(), version: { increment: 1 } },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -434,25 +280,13 @@ export class BankOperationsService {
|
|||||||
const period = expenses
|
const period = expenses
|
||||||
? undefined
|
? undefined
|
||||||
: periodBounds(query as BankOperationsQueryDto);
|
: periodBounds(query as BankOperationsQueryDto);
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.repository.read(async (tx) => {
|
||||||
await this.repository.card(tx, userId, id);
|
await this.repository.card(tx, userId, id);
|
||||||
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE,
|
||||||
page = query.page ?? FIRST_PAGE;
|
page = query.page ?? FIRST_PAGE;
|
||||||
const where: Prisma.BankCardOperationWhereInput = {
|
const { total, rows } = await this.repository.operationPage(
|
||||||
userId,
|
tx, userId, id, period, limit, page,
|
||||||
deletedAt: null,
|
);
|
||||||
OR: [{ cardId: id }, { targetCardId: id }],
|
|
||||||
...(period
|
|
||||||
? { date: { gte: period.from, lte: period.to } }
|
|
||||||
: { kind: BankOperationKind.Expense }),
|
|
||||||
};
|
|
||||||
const total = await tx.bankCardOperation.count({ where });
|
|
||||||
const rows = await tx.bankCardOperation.findMany({
|
|
||||||
where,
|
|
||||||
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
|
||||||
skip: (page - FIRST_PAGE) * limit,
|
|
||||||
take: limit,
|
|
||||||
});
|
|
||||||
return {
|
return {
|
||||||
items: await this.pageViews(tx, rows),
|
items: await this.pageViews(tx, rows),
|
||||||
total,
|
total,
|
||||||
|
|||||||
@@ -1,81 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
|
||||||
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits';
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import { conflict, missing, name } from './bank-card-input';
|
import { name } from './bank-card-input';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_CATEGORIES = 1;
|
|
||||||
const VERSION_INCREMENT = 1;
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExpenseCategoriesService {
|
export class ExpenseCategoriesService {
|
||||||
constructor(
|
constructor(private readonly repository: BankCardsRepository) {}
|
||||||
private readonly prisma: PrismaService,
|
|
||||||
private readonly repository: BankCardsRepository,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async categories(userId: string) {
|
async categories(userId: string) {
|
||||||
return (
|
return this.repository.categories(userId);
|
||||||
await this.prisma.expenseCategory.findMany({
|
|
||||||
where: { userId },
|
|
||||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
|
||||||
})
|
|
||||||
).map(({ id, name, seedKey, version }) => ({
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
isDefault: seedKey === 'other',
|
|
||||||
version,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createCategory(userId: string, value: string) {
|
createCategory(userId: string, value: string) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.createCategory(userId, label);
|
||||||
await this.repository.seed(tx, userId);
|
|
||||||
const row = await tx.expenseCategory.create({
|
|
||||||
data: {
|
|
||||||
id: randomUUID(),
|
|
||||||
userId,
|
|
||||||
name: label,
|
|
||||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
name: row.name,
|
|
||||||
isDefault: false,
|
|
||||||
version: row.version,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
updateCategory(userId: string, id: string, value: string, version: number) {
|
updateCategory(userId: string, id: string, value: string, version: number) {
|
||||||
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
const label = name(value, MAX_CATEGORY_NAME_LENGTH);
|
||||||
return this.repository.write(async (tx) => {
|
return this.repository.updateCategory(userId, id, label, version);
|
||||||
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
|
||||||
if (!row) missing();
|
|
||||||
if (
|
|
||||||
(
|
|
||||||
await tx.expenseCategory.updateMany({
|
|
||||||
where: { id, userId, version },
|
|
||||||
data: {
|
|
||||||
name: label,
|
|
||||||
nameKey: label.toLocaleLowerCase('ru-RU'),
|
|
||||||
version: { increment: 1 },
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).count !== EXPECTED_UPDATED_CATEGORIES
|
|
||||||
) {
|
|
||||||
conflict();
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id,
|
|
||||||
name: label,
|
|
||||||
isDefault: row.seedKey === 'other',
|
|
||||||
version: version + VERSION_INCREMENT,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
|
jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
|
|
||||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||||
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
|
|
||||||
import { BankOperationKind } from './bank-operation-kind';
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
import { ZERO_KOPECKS } from './rub-money';
|
import { ZERO_KOPECKS } from './rub-money';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
import {
|
||||||
|
operationFieldConflict,
|
||||||
|
OperationFieldRule,
|
||||||
|
operationKindChanged,
|
||||||
|
} from './bank-operation.rules';
|
||||||
|
|
||||||
|
const cardId = 'card-1';
|
||||||
|
|
||||||
|
function fields(
|
||||||
|
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
|
||||||
|
) {
|
||||||
|
return { kind: BankOperationKind.Topup, cardId, ...overrides };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('operationFieldConflict', () => {
|
||||||
|
it('allows a valid topup', () => {
|
||||||
|
expect(operationFieldConflict(fields())).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a topup with a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ originalExpenseId: 'op-1' }),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a non-transfer with a target card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ targetCardId: 'card-2' })),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expense', () => {
|
||||||
|
it('allows the default category to be resolved by the service', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Expense }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Expense,
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a plain categorized expense', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transfer', () => {
|
||||||
|
it('requires a target card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
|
||||||
|
).toBe(OperationFieldRule.MissingTargetCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a transfer to the same card', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: cardId,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.SameTargetCard);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a transfer with a category or purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: 'card-2',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Transfer,
|
||||||
|
targetCardId: 'card-2',
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('refund', () => {
|
||||||
|
it('requires a purchase link', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
|
||||||
|
).toBe(OperationFieldRule.MissingOriginalExpense);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects a category', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({
|
||||||
|
kind: BankOperationKind.Refund,
|
||||||
|
originalExpenseId: 'op-1',
|
||||||
|
categoryId: 'cat-1',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
).toBe(OperationFieldRule.InvalidCombination);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a linked refund', () => {
|
||||||
|
expect(
|
||||||
|
operationFieldConflict(
|
||||||
|
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
|
||||||
|
),
|
||||||
|
).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('operationKindChanged', () => {
|
||||||
|
it('flags kind and purchase link changes', () => {
|
||||||
|
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
|
||||||
|
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
|
||||||
|
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
|
||||||
|
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
import { BankOperationKind } from './bank-operation-kind';
|
||||||
|
|
||||||
|
export type OperationFields = {
|
||||||
|
cardId: string;
|
||||||
|
categoryId?: null | string;
|
||||||
|
kind: BankOperationKind;
|
||||||
|
originalExpenseId?: null | string;
|
||||||
|
targetCardId?: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export enum OperationFieldRule {
|
||||||
|
InvalidCombination = 'invalidCombination',
|
||||||
|
MissingOriginalExpense = 'missingOriginalExpense',
|
||||||
|
MissingTargetCard = 'missingTargetCard',
|
||||||
|
SameTargetCard = 'sameTargetCard',
|
||||||
|
}
|
||||||
|
|
||||||
|
function transferConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
if (!fields.targetCardId) {
|
||||||
|
return OperationFieldRule.MissingTargetCard;
|
||||||
|
}
|
||||||
|
if (fields.targetCardId === fields.cardId) {
|
||||||
|
return OperationFieldRule.SameTargetCard;
|
||||||
|
}
|
||||||
|
if (fields.categoryId || fields.originalExpenseId) {
|
||||||
|
return OperationFieldRule.InvalidCombination;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
if (fields.originalExpenseId) {
|
||||||
|
return OperationFieldRule.InvalidCombination;
|
||||||
|
}
|
||||||
|
// Expense without a category uses the user's default category in the service.
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function refundConflict(fields: OperationFields): null | OperationFieldRule {
|
||||||
|
return fields.originalExpenseId
|
||||||
|
? null
|
||||||
|
: OperationFieldRule.MissingOriginalExpense;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
|
||||||
|
* Returns the first violated rule, or null when the combination is allowed.
|
||||||
|
*/
|
||||||
|
export function operationFieldConflict(
|
||||||
|
fields: OperationFields,
|
||||||
|
): null | OperationFieldRule {
|
||||||
|
if (fields.kind === BankOperationKind.Transfer) {
|
||||||
|
return transferConflict(fields);
|
||||||
|
}
|
||||||
|
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
|
||||||
|
if (fields.kind === BankOperationKind.Expense) {
|
||||||
|
return expenseConflict(fields);
|
||||||
|
}
|
||||||
|
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
|
||||||
|
if (fields.kind === BankOperationKind.Refund) {
|
||||||
|
return refundConflict(fields);
|
||||||
|
}
|
||||||
|
// Topup: no target, no category, no purchase link.
|
||||||
|
return fields.originalExpenseId
|
||||||
|
? OperationFieldRule.InvalidCombination
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function operationKindChanged(
|
||||||
|
oldKind: string,
|
||||||
|
newKind: string,
|
||||||
|
oldOriginalExpenseId: null | string,
|
||||||
|
newOriginalExpenseId: null | string,
|
||||||
|
): boolean {
|
||||||
|
return (
|
||||||
|
oldKind !== newKind ||
|
||||||
|
oldOriginalExpenseId !== newOriginalExpenseId
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Money } from '../../../../common/money.types';
|
import type { Money } from '#src/common/money.types';
|
||||||
|
|
||||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||||
|
|
||||||
export const ZERO_KOPECKS = 0n;
|
export const ZERO_KOPECKS = 0n;
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
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 { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
conflict,
|
conflict,
|
||||||
missing,
|
missing,
|
||||||
} from '../application/bank-card-input';
|
} from '../application/bank-card-input';
|
||||||
|
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||||
|
|
||||||
const INITIAL_RETRY_COUNT = 0;
|
const INITIAL_RETRY_COUNT = 0;
|
||||||
const LAST_SEED_OFFSET = 1;
|
const LAST_SEED_OFFSET = 1;
|
||||||
|
const EXPECTED_UPDATED_CARDS = 1;
|
||||||
|
const EXPECTED_UPDATED_OPERATIONS = 1;
|
||||||
|
const EXPECTED_UPDATED_CATEGORIES = 1;
|
||||||
|
const VERSION_INCREMENT = 1;
|
||||||
|
|
||||||
type Tx = Prisma.TransactionClient;
|
type Tx = Prisma.TransactionClient;
|
||||||
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
const TRANSACTION_MAX_WAIT_MS = 10_000;
|
||||||
@@ -32,6 +38,220 @@ const seeds = [
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class BankCardsRepository {
|
export class BankCardsRepository {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
read<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
|
return this.prisma.$transaction(run);
|
||||||
|
}
|
||||||
|
|
||||||
|
cards(tx: Tx, userId: string, includeArchived = false) {
|
||||||
|
return tx.bankCard.findMany({
|
||||||
|
where: { userId, ...(includeArchived ? {} : { archivedAt: null }) },
|
||||||
|
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
cardOperations(tx: Tx, card: BankCard) {
|
||||||
|
return tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId: card.userId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ cardId: card.id }, { targetCardId: card.id }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
operationById(tx: Tx, id: string) {
|
||||||
|
return tx.bankCardOperation.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) {
|
||||||
|
return tx.bankCardOperation.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateOperation(
|
||||||
|
tx: Tx,
|
||||||
|
where: Prisma.BankCardOperationWhereInput,
|
||||||
|
data: Prisma.BankCardOperationUpdateManyMutationInput,
|
||||||
|
) {
|
||||||
|
const result = await tx.bankCardOperation.updateMany({ where, data });
|
||||||
|
if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict();
|
||||||
|
}
|
||||||
|
|
||||||
|
category(tx: Tx, userId: string, id: string) {
|
||||||
|
return tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultCategory(tx: Tx, userId: string) {
|
||||||
|
return tx.expenseCategory.findUnique({
|
||||||
|
where: { userId_seedKey: { userId, seedKey: 'other' } },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) {
|
||||||
|
return tx.bankCardOperation.findMany({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
originalExpenseId,
|
||||||
|
deletedAt: null,
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
dependentRefund(tx: Tx, userId: string, originalExpenseId: string) {
|
||||||
|
return tx.bankCardOperation.findFirst({
|
||||||
|
where: { userId, originalExpenseId, deletedAt: null },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) {
|
||||||
|
const cardIds = new Set(rows.flatMap((row) =>
|
||||||
|
[row.cardId, row.targetCardId].filter((id): id is string => id !== null),
|
||||||
|
));
|
||||||
|
const sourceIds = new Set(rows.flatMap((row) =>
|
||||||
|
row.originalExpenseId ? [row.originalExpenseId] : [],
|
||||||
|
));
|
||||||
|
const [cards, sources] = await Promise.all([
|
||||||
|
tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }),
|
||||||
|
tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }),
|
||||||
|
]);
|
||||||
|
const categoryIds = new Set(rows.map((row) =>
|
||||||
|
sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId,
|
||||||
|
).filter((id): id is string => id !== null));
|
||||||
|
const expenseIds = new Set(rows.flatMap((row) => {
|
||||||
|
const source = sources.find((item) => item.id === row.originalExpenseId);
|
||||||
|
return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : [];
|
||||||
|
}));
|
||||||
|
const [categories, refunds] = await Promise.all([
|
||||||
|
tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }),
|
||||||
|
tx.bankCardOperation.findMany({
|
||||||
|
where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
return { cards, sources, categories, refunds };
|
||||||
|
}
|
||||||
|
|
||||||
|
async operationPage(
|
||||||
|
tx: Tx,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
period: undefined | { from: string; to: string },
|
||||||
|
limit: number,
|
||||||
|
page: number,
|
||||||
|
) {
|
||||||
|
const where: Prisma.BankCardOperationWhereInput = {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
...(period
|
||||||
|
? { date: { gte: period.from, lte: period.to } }
|
||||||
|
: { kind: BankOperationKind.Expense }),
|
||||||
|
};
|
||||||
|
const total = await tx.bankCardOperation.count({ where });
|
||||||
|
const rows = await tx.bankCardOperation.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
skip: (page - FIRST_PAGE) * limit,
|
||||||
|
take: limit,
|
||||||
|
});
|
||||||
|
return { total, rows };
|
||||||
|
}
|
||||||
|
|
||||||
|
cardById(tx: Tx, id: string) {
|
||||||
|
return tx.bankCard.findUnique({ where: { id } });
|
||||||
|
}
|
||||||
|
|
||||||
|
createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) {
|
||||||
|
return tx.bankCard.create({ data });
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateCard(
|
||||||
|
tx: Tx,
|
||||||
|
userId: string,
|
||||||
|
id: string,
|
||||||
|
version: number,
|
||||||
|
data: Prisma.BankCardUpdateManyMutationInput,
|
||||||
|
) {
|
||||||
|
const changed = await tx.bankCard.updateMany({
|
||||||
|
where: { id, userId, version },
|
||||||
|
data,
|
||||||
|
});
|
||||||
|
if (changed.count !== EXPECTED_UPDATED_CARDS) conflict();
|
||||||
|
return this.card(tx, userId, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) {
|
||||||
|
return tx.bankCardOperation.findFirst({
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
deletedAt: null,
|
||||||
|
date: { lt: openingDate },
|
||||||
|
OR: [{ cardId: id }, { targetCardId: id }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async categories(userId: string) {
|
||||||
|
return (
|
||||||
|
await this.prisma.expenseCategory.findMany({
|
||||||
|
where: { userId },
|
||||||
|
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||||
|
})
|
||||||
|
).map(({ id, name, seedKey, version }) => ({
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
isDefault: seedKey === 'other',
|
||||||
|
version,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
createCategory(userId: string, label: string) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
await this.seed(tx, userId);
|
||||||
|
const row = await tx.expenseCategory.create({
|
||||||
|
data: {
|
||||||
|
id: randomUUID(),
|
||||||
|
userId,
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
isDefault: false,
|
||||||
|
version: row.version,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCategory(userId: string, id: string, label: string, version: number) {
|
||||||
|
return this.write(async (tx) => {
|
||||||
|
const row = await tx.expenseCategory.findFirst({ where: { id, userId } });
|
||||||
|
if (!row) missing();
|
||||||
|
if (
|
||||||
|
(
|
||||||
|
await tx.expenseCategory.updateMany({
|
||||||
|
where: { id, userId, version },
|
||||||
|
data: {
|
||||||
|
name: label,
|
||||||
|
nameKey: label.toLocaleLowerCase('ru-RU'),
|
||||||
|
version: { increment: 1 },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
).count !== EXPECTED_UPDATED_CATEGORIES
|
||||||
|
) {
|
||||||
|
conflict();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
isDefault: row.seedKey === 'other',
|
||||||
|
version: version + VERSION_INCREMENT,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
async write<T>(run: (tx: Tx) => Promise<T>): Promise<T> {
|
||||||
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ApiProperty,
|
ApiProperty,
|
||||||
ApiPropertyOptional,
|
ApiPropertyOptional,
|
||||||
|
getSchemaPath,
|
||||||
IntersectionType,
|
IntersectionType,
|
||||||
OmitType,
|
OmitType,
|
||||||
PartialType,
|
PartialType,
|
||||||
@@ -23,7 +24,8 @@ import {
|
|||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
FIRST_PAGE,
|
FIRST_PAGE,
|
||||||
MAX_CARD_NAME_LENGTH,
|
MAX_CARD_NAME_LENGTH,
|
||||||
@@ -153,6 +155,49 @@ export class CreateBankOperationDto {
|
|||||||
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||||
description?: string;
|
description?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
|
||||||
|
// invalid combinations keep the existing BANK_INVALID_INPUT response.
|
||||||
|
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
|
||||||
|
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
|
||||||
|
] as const) {}
|
||||||
|
|
||||||
|
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
|
||||||
|
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
|
||||||
|
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
|
||||||
|
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBankOperationSchema = {
|
||||||
|
oneOf: [
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
].map((model) => ({ $ref: getSchemaPath(model) })),
|
||||||
|
discriminator: {
|
||||||
|
propertyName: 'kind',
|
||||||
|
mapping: {
|
||||||
|
expense: getSchemaPath(CreateExpenseOperationDto),
|
||||||
|
topup: getSchemaPath(CreateTopupOperationDto),
|
||||||
|
transfer: getSchemaPath(CreateTransferOperationDto),
|
||||||
|
refund: getSchemaPath(CreateRefundOperationDto),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
export class UpdateBankOperationDto extends PartialType(
|
export class UpdateBankOperationDto extends PartialType(
|
||||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||||
{ skipNullProperties: false },
|
{ skipNullProperties: false },
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import {
|
import {
|
||||||
ApiBearerAuth,
|
ApiBearerAuth,
|
||||||
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiExtraModels,
|
||||||
ApiNoContentResponse,
|
ApiNoContentResponse,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiResponse,
|
ApiResponse,
|
||||||
@@ -27,8 +29,9 @@ import {
|
|||||||
import {
|
import {
|
||||||
ApiProblemResponses,
|
ApiProblemResponses,
|
||||||
ProblemDetailsDto,
|
ProblemDetailsDto,
|
||||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
} from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||||
|
|
||||||
import { BankCardsService } from '../application/bank-cards.service';
|
import { BankCardsService } from '../application/bank-cards.service';
|
||||||
import { BankOperationsService } from '../application/bank-operations.service';
|
import { BankOperationsService } from '../application/bank-operations.service';
|
||||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||||
@@ -46,6 +49,11 @@ import {
|
|||||||
CategoryNameDto,
|
CategoryNameDto,
|
||||||
CreateBankCardDto,
|
CreateBankCardDto,
|
||||||
CreateBankOperationDto,
|
CreateBankOperationDto,
|
||||||
|
createBankOperationSchema,
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
ExpenseCategoryDto,
|
ExpenseCategoryDto,
|
||||||
UpdateBankCardDto,
|
UpdateBankCardDto,
|
||||||
UpdateBankOperationDto,
|
UpdateBankOperationDto,
|
||||||
@@ -73,6 +81,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ApiTags('bank-cards')
|
@ApiTags('bank-cards')
|
||||||
|
@ApiExtraModels(
|
||||||
|
CreateExpenseOperationDto,
|
||||||
|
CreateTopupOperationDto,
|
||||||
|
CreateTransferOperationDto,
|
||||||
|
CreateRefundOperationDto,
|
||||||
|
)
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ApiProblemResponses()
|
@ApiProblemResponses()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
@@ -131,6 +145,7 @@ export class BankCardsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Post('operations')
|
@Post('operations')
|
||||||
|
@ApiBody({ schema: createBankOperationSchema })
|
||||||
@ApiCreatedResponse({ type: BankOperationDto })
|
@ApiCreatedResponse({ type: BankOperationDto })
|
||||||
createOperation(
|
createOperation(
|
||||||
@Req() req: AuthRequest,
|
@Req() req: AuthRequest,
|
||||||
|
|||||||
@@ -1,14 +1,12 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
|
|
||||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||||
|
|
||||||
export class UnrealizedReturnDto {
|
export class UnrealizedReturnDto {
|
||||||
@ApiProperty()
|
|
||||||
declare currency: string;
|
|
||||||
|
|
||||||
@ApiProperty({ example: '12.345678901' })
|
@ApiProperty({ example: '12.345678901' })
|
||||||
declare absolute: string;
|
declare absolute: string;
|
||||||
|
|
||||||
@@ -23,17 +21,17 @@ export class UnrealizedReturnDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class PortfolioSummaryDto {
|
export class PortfolioSummaryDto {
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare totalsByCurrency: MoneyDto[];
|
declare totals: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto, isArray: true })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare cashBalances: MoneyDto[];
|
declare cash: MoneyDto;
|
||||||
|
|
||||||
@ApiProperty({ type: PortfolioStructureDto })
|
@ApiProperty({ type: PortfolioStructureDto })
|
||||||
declare structure: PortfolioStructureDto;
|
declare structure: PortfolioStructureDto;
|
||||||
|
|
||||||
@ApiProperty({ type: UnrealizedReturnDto, isArray: true })
|
@ApiProperty({ type: UnrealizedReturnDto })
|
||||||
declare unrealizedReturnByCurrency: UnrealizedReturnDto[];
|
declare unrealizedReturn: UnrealizedReturnDto;
|
||||||
|
|
||||||
@ApiProperty({
|
@ApiProperty({
|
||||||
type: 'integer',
|
type: 'integer',
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { Request } from 'express';
|
|||||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
|
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||||
|
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
@@ -33,7 +33,7 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('keeps exact saved totals and independent missing summaries', async () => {
|
it('keeps exact saved totals and independent missing summaries', async () => {
|
||||||
const structure = { currencies: [], unvaluedCategories: [] };
|
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||||
portfolio.saved.mockResolvedValue({
|
portfolio.saved.mockResolvedValue({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [
|
accounts: [
|
||||||
@@ -44,11 +44,9 @@ describe('saved brokerage account projection', () => {
|
|||||||
openedOn: null,
|
openedOn: null,
|
||||||
meta,
|
meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
cash: { currency: 'RUB', value: '10.000000001' },
|
||||||
structure,
|
structure,
|
||||||
totalsByCurrency: [
|
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||||
{ currency: 'RUB', value: '100000000.000000001' },
|
|
||||||
],
|
|
||||||
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
positions: [{ type: 'STOCK', quantity: '1' }, { type: 'BOND', quantity: '2' }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -64,14 +62,14 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
const response = await service.list('owner');
|
const response = await service.list('owner');
|
||||||
expect(response.data?.[0].portfolioSummary).toEqual({
|
expect(response.data?.[0].portfolioSummary).toEqual({
|
||||||
cashBalances: [{ currency: 'RUB', value: '10.000000001' }],
|
cash: { currency: 'RUB', value: '10.000000001' },
|
||||||
structure,
|
structure,
|
||||||
totalsByCurrency: [{ currency: 'RUB', value: '100000000.000000001' }],
|
totals: { currency: 'RUB', value: '100000000.000000001' },
|
||||||
stockPositionCount: 1,
|
stockPositionCount: 1,
|
||||||
bondPositionCount: 1,
|
bondPositionCount: 1,
|
||||||
fundPositionCount: 0,
|
fundPositionCount: 0,
|
||||||
totalPositionCount: 2,
|
totalPositionCount: 2,
|
||||||
unrealizedReturnByCurrency: [],
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||||
});
|
});
|
||||||
expect(response.data?.[1].portfolioSummary).toBeNull();
|
expect(response.data?.[1].portfolioSummary).toBeNull();
|
||||||
expect(response.meta.availability).toBe('available');
|
expect(response.meta.availability).toBe('available');
|
||||||
@@ -79,74 +77,65 @@ describe('saved brokerage account projection', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('returns an empty summary from an empty saved portfolio', async () => {
|
it('returns an empty summary from an empty saved portfolio', async () => {
|
||||||
const structure = { currencies: [], unvaluedPositions: [], unknownValues: [] };
|
const structure = { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] };
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
id: 'empty', name: 'Empty', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
snapshot: { cashBalances: [], structure, totalsByCurrency: [], positions: [] },
|
snapshot: { cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' }, positions: [] },
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toEqual({
|
||||||
cashBalances: [], structure, totalsByCurrency: [],
|
cash: { currency: 'RUB', value: '0' }, structure, totals: { currency: 'RUB', value: '0' },
|
||||||
stockPositionCount: 0, bondPositionCount: 0,
|
stockPositionCount: 0, bondPositionCount: 0,
|
||||||
fundPositionCount: 0, totalPositionCount: 0,
|
fundPositionCount: 0, totalPositionCount: 0,
|
||||||
unrealizedReturnByCurrency: [],
|
unrealizedReturn: { absolute: '0', costBasis: '0', percent: null, partial: false },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calculates exact returns by currency, partial data, and zero cost basis', async () => {
|
it('calculates exact returns, partial data, and zero cost basis', async () => {
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [], structure: {}, totalsByCurrency: [
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '100' },
|
||||||
{ currency: 'RUB', value: '100' }, { currency: 'USD', value: '20' },
|
|
||||||
],
|
|
||||||
positions: [
|
positions: [
|
||||||
{ type: 'STOCK', quantity: '2', averagePurchasePrice: { currency: 'RUB', value: '10.000000001' }, absoluteReturn: { currency: 'RUB', value: '0.000000003' } },
|
{ 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: '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: 'FUND', quantity: '3', averagePurchasePrice: { currency: 'RUB', value: '2.5' }, absoluteReturn: null },
|
||||||
{ type: 'STOCK', quantity: '4', averagePurchasePrice: { currency: 'USD', value: '0' }, absoluteReturn: { currency: 'USD', value: '2.5' } },
|
{ type: 'BOND', quantity: '1', currentValue: { currency: 'RUB', value: '7' }, averagePurchasePrice: null, absoluteReturn: null },
|
||||||
{ 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({
|
expect((await service.list('owner')).data?.[0].portfolioSummary).toMatchObject({
|
||||||
stockPositionCount: 2, bondPositionCount: 2,
|
stockPositionCount: 1, bondPositionCount: 2,
|
||||||
fundPositionCount: 1, totalPositionCount: 6,
|
fundPositionCount: 1, totalPositionCount: 4,
|
||||||
unrealizedReturnByCurrency: [
|
unrealizedReturn: { absolute: '1.250000003', costBasis: '30.000000002', percent: '4.17', partial: true },
|
||||||
{ 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 () => {
|
it('omits return percent for short positions', async () => {
|
||||||
portfolio.saved.mockResolvedValueOnce({
|
portfolio.saved.mockResolvedValueOnce({
|
||||||
membership: meta,
|
membership: meta,
|
||||||
accounts: [{
|
accounts: [{
|
||||||
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
id: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, meta,
|
||||||
snapshot: {
|
snapshot: {
|
||||||
cashBalances: [], structure: {}, totalsByCurrency: [],
|
cash: { currency: 'RUB', value: '0' }, structure: {}, totals: { currency: 'RUB', value: '0' },
|
||||||
positions: [
|
positions: [
|
||||||
{ type: 'STOCK', quantity: '-2', averagePurchasePrice: { currency: 'RUB', value: '100' }, absoluteReturn: { currency: 'RUB', value: '-20' } },
|
{ 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' } },
|
{ type: 'STOCK', quantity: '1', averagePurchasePrice: { currency: 'RUB', value: '10' }, absoluteReturn: { currency: 'RUB', value: '1' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
|
|
||||||
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturnByCurrency).toEqual([
|
expect((await service.list('owner')).data?.[0]?.portfolioSummary?.unrealizedReturn).toEqual(
|
||||||
{ currency: 'RUB', absolute: '-20', costBasis: '-200', percent: null, partial: false },
|
{ absolute: '-19', costBasis: '-190', 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 () => {
|
it('observes the latest hourly walk without starting another one', async () => {
|
||||||
|
|||||||
@@ -2,14 +2,15 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
add,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
isZero,
|
isZero,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
|
||||||
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||||
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
import { hourlyAttempt, TbankSyncRepository } from '../tbank-sync/tbank-sync.api';
|
||||||
import {
|
import {
|
||||||
@@ -21,61 +22,42 @@ import {
|
|||||||
const PERCENT_DECIMAL_PLACES = 2;
|
const PERCENT_DECIMAL_PLACES = 2;
|
||||||
const ZERO_QUANTITY = 0n;
|
const ZERO_QUANTITY = 0n;
|
||||||
|
|
||||||
function unrealizedReturns(positions: PortfolioPositionDto[]): UnrealizedReturnDto[] {
|
function unrealizedReturn(positions: PortfolioPositionDto[]): UnrealizedReturnDto {
|
||||||
// Cash-like CURRENCY positions must not duplicate the separately returned cash balances.
|
const absoluteReturn = positions.reduce(
|
||||||
const investments = positions.filter((position) => position.type !== 'CURRENCY');
|
(sum, position) => position.absoluteReturn
|
||||||
const absolute = sumMoneyByCurrency(
|
? add(sum, decimalFromString(position.absoluteReturn.value))
|
||||||
investments.flatMap((position) => position.absoluteReturn ? [position.absoluteReturn] : []),
|
: sum,
|
||||||
|
decimalFromString('0'),
|
||||||
);
|
);
|
||||||
const costs = sumMoneyByCurrency(
|
const costBasis = positions
|
||||||
investments.flatMap((position) =>
|
.filter((position) => position.absoluteReturn && position.averagePurchasePrice)
|
||||||
position.absoluteReturn && position.averagePurchasePrice
|
.reduce(
|
||||||
? [{
|
(sum, position) =>
|
||||||
currency: position.absoluteReturn.currency,
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion -- Guarded by the filter above.
|
||||||
value: formatDecimal(multiply(
|
add(sum, multiply(decimalFromString(position.averagePurchasePrice!.value), decimalFromString(position.quantity))),
|
||||||
decimalFromString(position.averagePurchasePrice.value),
|
decimalFromString('0'),
|
||||||
decimalFromString(position.quantity),
|
|
||||||
)),
|
|
||||||
}]
|
|
||||||
: [],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
const partial = new Set(
|
const partial = positions.some(
|
||||||
investments.flatMap((position) => {
|
(position) => !position.absoluteReturn && (position.averagePurchasePrice || position.currentValue),
|
||||||
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.
|
// A short position has negative cost basis, so a percent would flip sign or net out.
|
||||||
const withShorts = new Set(
|
const withShorts = positions.some(
|
||||||
investments.flatMap((position) => {
|
(position) =>
|
||||||
const money = position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue;
|
(position.absoluteReturn ?? position.averagePurchasePrice ?? position.currentValue) &&
|
||||||
return money && decimalFromString(position.quantity).value < ZERO_QUANTITY ? [money.currency] : [];
|
decimalFromString(position.quantity).value < ZERO_QUANTITY,
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
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 {
|
return {
|
||||||
currency,
|
absolute: formatDecimal(absoluteReturn),
|
||||||
absolute: absoluteReturn,
|
costBasis: formatDecimal(costBasis),
|
||||||
costBasis,
|
percent: isZero(costBasis) || withShorts
|
||||||
percent: isZero(cost) || withShorts.has(currency)
|
|
||||||
? null
|
? null
|
||||||
: formatDecimal(divide(
|
: formatDecimal(divide(
|
||||||
multiply(decimalFromString(absoluteReturn), decimalFromString('100')),
|
multiply(absoluteReturn, decimalFromString('100')),
|
||||||
cost,
|
costBasis,
|
||||||
PERCENT_DECIMAL_PLACES,
|
PERCENT_DECIMAL_PLACES,
|
||||||
)),
|
)),
|
||||||
partial: partial.has(currency),
|
partial,
|
||||||
};
|
};
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -97,10 +79,10 @@ export class BrokerageAccountsService {
|
|||||||
openedOn: account.openedOn,
|
openedOn: account.openedOn,
|
||||||
portfolioSummary: account.snapshot
|
portfolioSummary: account.snapshot
|
||||||
? {
|
? {
|
||||||
totalsByCurrency: account.snapshot.totalsByCurrency,
|
totals: account.snapshot.totals,
|
||||||
cashBalances: account.snapshot.cashBalances,
|
cash: account.snapshot.cash,
|
||||||
structure: account.snapshot.structure,
|
structure: account.snapshot.structure,
|
||||||
unrealizedReturnByCurrency: unrealizedReturns(account.snapshot.positions),
|
unrealizedReturn: unrealizedReturn(account.snapshot.positions),
|
||||||
stockPositionCount: account.snapshot.positions.filter(
|
stockPositionCount: account.snapshot.positions.filter(
|
||||||
(position) => position.type === 'STOCK',
|
(position) => position.type === 'STOCK',
|
||||||
).length,
|
).length,
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import type { Request } from 'express';
|
|||||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
|
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
|
||||||
import {
|
import {
|
||||||
IncomeAnalyticsQueryDto,
|
IncomeAnalyticsQueryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
|
|||||||
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||||
allSummary(
|
allSummary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.summary(
|
return this.income.summary(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
'all',
|
'all',
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
|
|||||||
'all',
|
'all',
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
|
|||||||
summary(
|
summary(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('accountId') accountId: string,
|
@Param('accountId') accountId: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.summary(
|
return this.income.summary(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
accountId,
|
accountId,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
|
|||||||
accountId,
|
accountId,
|
||||||
query.from,
|
query.from,
|
||||||
query.to,
|
query.to,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsOptional, Matches } from 'class-validator';
|
import { IsOptional, Matches } from 'class-validator';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InvestmentDependencyDto,
|
InvestmentDependencyDto,
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../investment-resource.dto';
|
} from '../investment-resource.dto';
|
||||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||||
|
|
||||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
export class IncomeAnalyticsQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
format: 'date',
|
format: 'date',
|
||||||
description:
|
description:
|
||||||
@@ -39,10 +39,7 @@ export class IncomePeriodDto {
|
|||||||
declare to: string;
|
declare to: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeCurrencySummaryDto {
|
export class IncomeTotalDto {
|
||||||
@ApiProperty()
|
|
||||||
declare currency: string;
|
|
||||||
|
|
||||||
@ApiProperty({ type: MoneyDto })
|
@ApiProperty({ type: MoneyDto })
|
||||||
declare coupon: MoneyDto;
|
declare coupon: MoneyDto;
|
||||||
|
|
||||||
@@ -60,8 +57,8 @@ export class IncomeSummaryDto {
|
|||||||
@ApiProperty({ type: IncomePeriodDto })
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
declare period: IncomePeriodDto;
|
declare period: IncomePeriodDto;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
@ApiProperty({ type: IncomeTotalDto })
|
||||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
declare total: IncomeTotalDto;
|
||||||
|
|
||||||
@ApiProperty({ format: 'date-time' })
|
@ApiProperty({ format: 'date-time' })
|
||||||
declare calculatedAt: string;
|
declare calculatedAt: string;
|
||||||
@@ -71,8 +68,8 @@ export class IncomeBucketDto {
|
|||||||
@ApiProperty({ type: IncomePeriodDto })
|
@ApiProperty({ type: IncomePeriodDto })
|
||||||
declare period: IncomePeriodDto;
|
declare period: IncomePeriodDto;
|
||||||
|
|
||||||
@ApiProperty({ type: IncomeCurrencySummaryDto, isArray: true })
|
@ApiProperty({ type: IncomeTotalDto })
|
||||||
declare totalsByCurrency: IncomeCurrencySummaryDto[];
|
declare total: IncomeTotalDto;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class IncomeInstrumentDto {
|
export class IncomeInstrumentDto {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
jest.mock('../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||||
@@ -57,22 +57,22 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
ticker: 'T',
|
ticker: 'T',
|
||||||
instrumentType: 'bond',
|
instrumentType: 'bond',
|
||||||
},
|
},
|
||||||
key: 'uid:UID:RUB',
|
key: 'uid:UID',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
|
identity: { figi: 'FIGI', ticker: 'T', instrumentType: 'bond' },
|
||||||
key: 'figi:FIGI:RUB',
|
key: 'figi:FIGI',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
identity: { ticker: 'T', instrumentType: 'bond' },
|
identity: { ticker: 'T', instrumentType: 'bond' },
|
||||||
key: 'ticker:T:bond:RUB',
|
key: 'ticker:T:bond',
|
||||||
},
|
},
|
||||||
{ identity: { ticker: 'T' }, key: 'ticker:T::RUB' },
|
{ identity: { ticker: 'T' }, key: 'ticker:T:' },
|
||||||
{
|
{
|
||||||
identity: { ticker: null, instrumentType: 'bond' },
|
identity: { ticker: null, instrumentType: 'bond' },
|
||||||
key: 'ticker::bond:RUB',
|
key: 'ticker::bond',
|
||||||
},
|
},
|
||||||
{ identity: { ticker: null }, key: 'operation:receipt:RUB' },
|
{ identity: { ticker: null }, key: 'operation:receipt' },
|
||||||
])(
|
])(
|
||||||
'uses one membership for entry and aggregate: $key',
|
'uses one membership for entry and aggregate: $key',
|
||||||
async ({ identity, key }) => {
|
async ({ identity, key }) => {
|
||||||
@@ -102,7 +102,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it('preserves entry order, repeated IDs, currency groups and signed exact totals', async () => {
|
it('preserves entry order, repeated IDs and signed exact totals', async () => {
|
||||||
const selected = [
|
const selected = [
|
||||||
{
|
{
|
||||||
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
...entry('same-id', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
@@ -112,10 +112,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
|
...entry('same-id', 'OPERATION_TYPE_DIVIDEND', '-0.25', 'RUB'),
|
||||||
instrumentUid: 'UID',
|
instrumentUid: 'UID',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
...entry('usd', 'OPERATION_TYPE_COUPON', '20', 'USD'),
|
|
||||||
instrumentUid: 'UID',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
seed(selected);
|
seed(selected);
|
||||||
|
|
||||||
@@ -131,7 +127,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
expect(result.entries).toEqual(
|
expect(result.entries).toEqual(
|
||||||
selected.map((item) => ({
|
selected.map((item) => ({
|
||||||
...item,
|
...item,
|
||||||
instrumentKey: `uid:UID:${item.payment?.currency}`,
|
instrumentKey: 'uid:UID',
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
@@ -146,14 +142,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
),
|
),
|
||||||
).toEqual([
|
).toEqual([
|
||||||
{
|
{
|
||||||
key: 'uid:UID:USD',
|
key: 'uid:UID',
|
||||||
receiptCount: 1,
|
|
||||||
coupon: { currency: 'USD', value: '20' },
|
|
||||||
dividend: { currency: 'USD', value: '0' },
|
|
||||||
total: { currency: 'USD', value: '20' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'uid:UID:RUB',
|
|
||||||
receiptCount: 2,
|
receiptCount: 2,
|
||||||
coupon: { currency: 'RUB', value: '10.25' },
|
coupon: { currency: 'RUB', value: '10.25' },
|
||||||
dividend: { currency: 'RUB', value: '-0.25' },
|
dividend: { currency: 'RUB', value: '-0.25' },
|
||||||
@@ -162,7 +151,7 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
]);
|
]);
|
||||||
expect(
|
expect(
|
||||||
result.entries
|
result.entries
|
||||||
.filter((item) => item.instrumentKey === 'uid:UID:RUB')
|
.filter((item) => item.instrumentKey === 'uid:UID')
|
||||||
.map((item) => item.payment?.value),
|
.map((item) => item.payment?.value),
|
||||||
).toEqual(['10.25', '-0.25']);
|
).toEqual(['10.25', '-0.25']);
|
||||||
});
|
});
|
||||||
@@ -191,26 +180,32 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
expect(response.data?.buckets).toEqual([
|
expect(response.data?.buckets).toEqual([
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-14', to: '2026-01-14' },
|
period: { from: '2026-01-14', to: '2026-01-14' },
|
||||||
totalsByCurrency: [expect.objectContaining({
|
total: {
|
||||||
|
coupon: { currency: 'RUB', value: '0.1' },
|
||||||
|
dividend: { currency: 'RUB', value: '0' },
|
||||||
total: { currency: 'RUB', value: '0.1' },
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
})],
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-15', to: '2026-01-15' },
|
period: { from: '2026-01-15', to: '2026-01-15' },
|
||||||
totalsByCurrency: [expect.objectContaining({
|
total: {
|
||||||
coupon: { currency: 'RUB', value: '0.2' },
|
coupon: { currency: 'RUB', value: '0.2' },
|
||||||
dividend: { currency: 'RUB', value: '-0.1' },
|
dividend: { currency: 'RUB', value: '-0.1' },
|
||||||
total: { currency: 'RUB', value: '0.1' },
|
total: { currency: 'RUB', value: '0.1' },
|
||||||
})],
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
period: { from: '2026-01-16', to: '2026-01-16' },
|
period: { from: '2026-01-16', to: '2026-01-16' },
|
||||||
totalsByCurrency: [],
|
total: {
|
||||||
|
coupon: { currency: 'RUB', value: '0' },
|
||||||
|
dividend: { currency: 'RUB', value: '0' },
|
||||||
|
total: { currency: 'RUB', value: '0' },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('aggregates only exact coupon and dividend payments by source currency', async () => {
|
it('aggregates only exact coupon and dividend payments', async () => {
|
||||||
operations.collectRanges.mockImplementation(
|
operations.collectRanges.mockImplementation(
|
||||||
(
|
(
|
||||||
_user: string,
|
_user: string,
|
||||||
@@ -223,7 +218,6 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
entry('coupon', 'OPERATION_TYPE_COUPON', '10.25', 'RUB'),
|
||||||
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
entry('dividend', 'OPERATION_TYPE_DIVIDEND', '-2.5', 'RUB'),
|
||||||
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
entry('transfer', 'OPERATION_TYPE_DIVIDEND_TRANSFER', '99', 'RUB'),
|
||||||
entry('usd', 'OPERATION_TYPE_COUPON', '1', 'USD'),
|
|
||||||
],
|
],
|
||||||
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
].map((items, index) => ({ items, meta: completeMeta(index) }));
|
||||||
},
|
},
|
||||||
@@ -238,22 +232,14 @@ describe('IncomeAnalyticsService', () => {
|
|||||||
|
|
||||||
const result = response.data;
|
const result = response.data;
|
||||||
assert(result);
|
assert(result);
|
||||||
expect(result.totalsByCurrency).toEqual([
|
expect(result.total).toEqual({
|
||||||
expect.objectContaining({
|
|
||||||
currency: 'RUB',
|
|
||||||
coupon: { currency: 'RUB', value: '10.25' },
|
coupon: { currency: 'RUB', value: '10.25' },
|
||||||
dividend: { currency: 'RUB', value: '-2.5' },
|
dividend: { currency: 'RUB', value: '-2.5' },
|
||||||
total: { currency: 'RUB', value: '7.75' },
|
total: { currency: 'RUB', value: '7.75' },
|
||||||
}),
|
});
|
||||||
expect.objectContaining({
|
|
||||||
currency: 'USD',
|
|
||||||
total: { currency: 'USD', value: '1' },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
expect(result.entries.map((entry) => entry.id)).toEqual([
|
expect(result.entries.map((entry) => entry.id)).toEqual([
|
||||||
'coupon',
|
'coupon',
|
||||||
'dividend',
|
'dividend',
|
||||||
'usd',
|
|
||||||
]);
|
]);
|
||||||
expect(result.buckets).toHaveLength(31);
|
expect(result.buckets).toHaveLength(31);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import {
|
|||||||
compare,
|
compare,
|
||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
} from '../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||||
|
|
||||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
@@ -17,11 +18,11 @@ import {
|
|||||||
import {
|
import {
|
||||||
IncomeAnalyticsEntryDto,
|
IncomeAnalyticsEntryDto,
|
||||||
IncomeAnalyticsResourceDto,
|
IncomeAnalyticsResourceDto,
|
||||||
IncomeCurrencySummaryDto,
|
|
||||||
IncomeDependencyDto,
|
IncomeDependencyDto,
|
||||||
IncomeInstrumentDto,
|
IncomeInstrumentDto,
|
||||||
IncomeSummaryDto,
|
IncomeSummaryDto,
|
||||||
IncomeSummaryResourceDto,
|
IncomeSummaryResourceDto,
|
||||||
|
IncomeTotalDto,
|
||||||
} from './income-analytics.dto';
|
} from './income-analytics.dto';
|
||||||
import {
|
import {
|
||||||
IncomeBucketGranularity,
|
IncomeBucketGranularity,
|
||||||
@@ -43,14 +44,12 @@ export class IncomeAnalyticsService {
|
|||||||
async summary(
|
async summary(
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
observe = false,
|
|
||||||
): Promise<IncomeSummaryResourceDto> {
|
): Promise<IncomeSummaryResourceDto> {
|
||||||
const plan = planIncomePeriod(undefined, undefined);
|
const plan = planIncomePeriod(undefined, undefined);
|
||||||
const [selected] = await this.operations.collectRanges(
|
const [selected] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
() => [plan.selected],
|
() => [plan.selected],
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||||
assert(selected.meta.lastSuccessfulAt);
|
assert(selected.meta.lastSuccessfulAt);
|
||||||
@@ -70,14 +69,12 @@ export class IncomeAnalyticsService {
|
|||||||
accountId: string,
|
accountId: string,
|
||||||
from: string | undefined,
|
from: string | undefined,
|
||||||
to: string | undefined,
|
to: string | undefined,
|
||||||
observe = false,
|
|
||||||
): Promise<IncomeAnalyticsResourceDto> {
|
): Promise<IncomeAnalyticsResourceDto> {
|
||||||
const plan = planIncomePeriod(from, to);
|
const plan = planIncomePeriod(from, to);
|
||||||
const [current] = await this.operations.collectRanges(
|
const [current] = await this.operations.collectRanges(
|
||||||
userId,
|
userId,
|
||||||
accountId,
|
accountId,
|
||||||
() => [plan.selected],
|
() => [plan.selected],
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
const dependencies: IncomeDependencyDto[] = [
|
const dependencies: IncomeDependencyDto[] = [
|
||||||
{ ...current.meta, role: 'selected' },
|
{ ...current.meta, role: 'selected' },
|
||||||
@@ -108,7 +105,7 @@ export class IncomeAnalyticsService {
|
|||||||
assert(current.meta.lastSuccessfulAt);
|
assert(current.meta.lastSuccessfulAt);
|
||||||
const selected = qualifying(current.items).map((entry) => ({
|
const selected = qualifying(current.items).map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
instrumentKey: `${instrumentKey(entry)}:${entry.payment?.currency ?? ''}`,
|
instrumentKey: instrumentKey(entry),
|
||||||
}));
|
}));
|
||||||
return {
|
return {
|
||||||
data: {
|
data: {
|
||||||
@@ -142,23 +139,19 @@ function summaryResponse(
|
|||||||
entries: BrokerageOperationDto[],
|
entries: BrokerageOperationDto[],
|
||||||
calculatedAt: string,
|
calculatedAt: string,
|
||||||
): IncomeSummaryDto {
|
): IncomeSummaryDto {
|
||||||
return { accountId, period, totalsByCurrency: totals(entries), calculatedAt };
|
return { accountId, period, total: totals(entries), calculatedAt };
|
||||||
}
|
}
|
||||||
|
|
||||||
function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
function totals(entries: BrokerageOperationDto[]): IncomeTotalDto {
|
||||||
const values = new Map<string, Total>();
|
const total = zeroTotal();
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
if (!entry.payment) continue;
|
if (!entry.payment) continue;
|
||||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
|
||||||
const value = decimalFromString(entry.payment.value);
|
const value = decimalFromString(entry.payment.value);
|
||||||
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||||
total.coupon = add(total.coupon, value);
|
total.coupon = add(total.coupon, value);
|
||||||
} else total.dividend = add(total.dividend, value);
|
} else total.dividend = add(total.dividend, value);
|
||||||
values.set(entry.payment.currency, total);
|
|
||||||
}
|
}
|
||||||
return [...values]
|
return presentTotal(total);
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([currency, total]) => presentTotal(currency, total));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buckets(
|
function buckets(
|
||||||
@@ -172,7 +165,7 @@ function buckets(
|
|||||||
}));
|
}));
|
||||||
return rangeBuckets(range, granularity).map((period) => ({
|
return rangeBuckets(range, granularity).map((period) => ({
|
||||||
period,
|
period,
|
||||||
totalsByCurrency: totals(
|
total: totals(
|
||||||
datedEntries
|
datedEntries
|
||||||
.filter(({ date }) => date >= period.from && date <= period.to)
|
.filter(({ date }) => date >= period.from && date <= period.to)
|
||||||
.map(({ entry }) => entry),
|
.map(({ entry }) => entry),
|
||||||
@@ -192,7 +185,7 @@ function instruments(
|
|||||||
return [...groups]
|
return [...groups]
|
||||||
.map(([key, group]) => {
|
.map(([key, group]) => {
|
||||||
const first = group[0];
|
const first = group[0];
|
||||||
const [total] = totals(group);
|
const total = totals(group);
|
||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
figi: first.figi,
|
figi: first.figi,
|
||||||
@@ -228,15 +221,10 @@ function zeroTotal(): Total {
|
|||||||
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
return { coupon: { value: 0n, scale: 0 }, dividend: { value: 0n, scale: 0 } };
|
||||||
}
|
}
|
||||||
|
|
||||||
function presentTotal(
|
function presentTotal(value: Total): IncomeTotalDto {
|
||||||
currency: string,
|
|
||||||
value: Total,
|
|
||||||
): IncomeCurrencySummaryDto {
|
|
||||||
const total = add(value.coupon, value.dividend);
|
|
||||||
return {
|
return {
|
||||||
currency,
|
coupon: { currency: 'RUB', value: formatDecimal(value.coupon) },
|
||||||
coupon: { currency, value: formatDecimal(value.coupon) },
|
dividend: { currency: 'RUB', value: formatDecimal(value.dividend) },
|
||||||
dividend: { currency, value: formatDecimal(value.dividend) },
|
total: { currency: 'RUB', value: formatDecimal(add(value.coupon, value.dividend)) },
|
||||||
total: { currency, value: formatDecimal(total) },
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { BadRequestException } from '@nestjs/common';
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DateOnlyRange,
|
DateOnlyRange,
|
||||||
dateParts,
|
dateParts,
|
||||||
@@ -8,7 +9,7 @@ import {
|
|||||||
utcDate,
|
utcDate,
|
||||||
} from '../investment-calendar';
|
} from '../investment-calendar';
|
||||||
|
|
||||||
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
export enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||||
|
|
||||||
const MONDAY_WEEKDAY_INDEX = 1;
|
const MONDAY_WEEKDAY_INDEX = 1;
|
||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
const INCLUSIVE_DAY_OFFSET = 1;
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
|
|||||||
jest.mock('./catalog-enrichment.service', () => ({
|
jest.mock('./catalog-enrichment.service', () => ({
|
||||||
CatalogEnrichmentService: class {},
|
CatalogEnrichmentService: class {},
|
||||||
}));
|
}));
|
||||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||||
TbankAccessService: class {},
|
TbankAccessService: class {},
|
||||||
TbankClient: class {},
|
TbankClient: class {},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -5,15 +5,16 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankShare,
|
type TbankShare,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import {
|
||||||
candleGaps,
|
candleGaps,
|
||||||
@@ -98,7 +99,7 @@ export class CandleService {
|
|||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(userId: string, input: CandleInput, observe = false) {
|
async read(userId: string, input: CandleInput) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const [detail] = await this.sources.readMany(
|
const [detail] = await this.sources.readMany(
|
||||||
userId,
|
userId,
|
||||||
@@ -118,7 +119,7 @@ export class CandleService {
|
|||||||
const hasCompleteRange = Boolean(
|
const hasCompleteRange = Boolean(
|
||||||
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
|
||||||
);
|
);
|
||||||
if (!observe && (!saved || !hasCompleteRange)) {
|
if (!saved || !hasCompleteRange) {
|
||||||
await this.request(userId, revision, input, now);
|
await this.request(userId, revision, input, now);
|
||||||
}
|
}
|
||||||
const work = await this.repository.work(userId, revision, scope(input));
|
const work = await this.repository.work(userId, revision, scope(input));
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CatalogDiscoveryService } from './catalog-discovery.service';
|
||||||
|
|
||||||
|
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||||
|
|
||||||
|
describe('catalog discovery currency filtering', () => {
|
||||||
|
it('uses details to filter InstrumentShort results before resolving ticker ambiguity', async () => {
|
||||||
|
const short = (uid: string) => ({ uid, ticker: 'SBER', instrumentKind: 'INSTRUMENT_TYPE_SHARE' });
|
||||||
|
const client = {
|
||||||
|
findShares: jest.fn().mockResolvedValue([short('a'), short('b')]),
|
||||||
|
getShare: jest.fn().mockImplementation((_token: string, uid: string) =>
|
||||||
|
Promise.resolve({ uid, ticker: 'SBER', currency: uid === 'a' ? 'USD' : 'RUB' })),
|
||||||
|
getLastPrices: jest.fn().mockResolvedValue([]),
|
||||||
|
};
|
||||||
|
const access = { online: jest.fn().mockImplementation((_user: string, run: (connection: { token: string }) => unknown) => run({ token: 'fixture' })) };
|
||||||
|
const service = new CatalogDiscoveryService(access as never, client as never);
|
||||||
|
const search = await service.search('user', { instrumentType: 'share', query: 'SBER' });
|
||||||
|
expect(search.data.map((item) => item.uid)).toEqual(['b']);
|
||||||
|
expect((await service.byTicker('user', 'share', 'SBER')).data?.uid).toBe('b');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -8,16 +8,15 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
|
||||||
catalogQuery,
|
catalogQuery,
|
||||||
type CatalogQuery,
|
type CatalogQuery,
|
||||||
catalogScope,
|
catalogScope,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
|
||||||
import {
|
import {
|
||||||
catalogSearchLimit,
|
catalogSearchLimit,
|
||||||
selectCatalogInstruments,
|
selectCatalogInstruments,
|
||||||
@@ -25,7 +24,6 @@ import {
|
|||||||
import { publicInstrument } from './public-instrument';
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||||
const NO_MATCHES = 0;
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
const FIRST_RESULT_OFFSET = 0;
|
||||||
|
|
||||||
const tickerAmbiguityLimit = 2;
|
const tickerAmbiguityLimit = 2;
|
||||||
@@ -33,101 +31,26 @@ const tickerAmbiguityLimit = 2;
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class CatalogDiscoveryService {
|
export class CatalogDiscoveryService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly repository: CatalogDiscoveryRepository,
|
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly enrichment: CatalogEnrichmentService,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async read(userId: string, input: CatalogQuery) {
|
async search(userId: string, input: CatalogQuery) {
|
||||||
input = catalogQuery(input);
|
return this.onlineSearch(userId, catalogQuery(input));
|
||||||
const connection = await this.access.local(userId);
|
|
||||||
const saved = await this.repository.read(input);
|
|
||||||
return {
|
|
||||||
data: saved.data,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: catalogScope(input),
|
|
||||||
dataRevision: saved.dataRevision,
|
|
||||||
availability: saved.data === null ? 'missing' : 'complete',
|
|
||||||
fetchedAt: saved.fetchedAt,
|
|
||||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
||||||
work: await this.repository.work(userId, connection.revision, input),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async search(userId: string, input: CatalogQuery, observe = false) {
|
|
||||||
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
|
|
||||||
const discovery = await this.read(userId, input);
|
|
||||||
const sources = discovery.data
|
|
||||||
? await this.enrichment.readMany(
|
|
||||||
userId,
|
|
||||||
input.instrumentType,
|
|
||||||
discovery.data.map((item) => item.uid),
|
|
||||||
observe,
|
|
||||||
)
|
|
||||||
: [];
|
|
||||||
const dependencies = [
|
|
||||||
discovery.meta,
|
|
||||||
...sources.map((source) => source.meta),
|
|
||||||
];
|
|
||||||
return {
|
|
||||||
data:
|
|
||||||
discovery.data?.map((short) => ({
|
|
||||||
...short,
|
|
||||||
detail:
|
|
||||||
sources.find(
|
|
||||||
(source) =>
|
|
||||||
source.input.uid === short.uid &&
|
|
||||||
source.input.kind === 'detail',
|
|
||||||
)?.instrument ?? null,
|
|
||||||
price:
|
|
||||||
sources.find(
|
|
||||||
(source) =>
|
|
||||||
source.input.uid === short.uid &&
|
|
||||||
source.input.kind === 'last-price',
|
|
||||||
)?.price ?? null,
|
|
||||||
})) ?? null,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
|
|
||||||
dataRevision: dependencies
|
|
||||||
.map((dependency) => dependency.dataRevision)
|
|
||||||
.join('/'),
|
|
||||||
availability:
|
|
||||||
discovery.data === null
|
|
||||||
? 'missing'
|
|
||||||
: sources.every((source) => source.meta.completeness === 'complete')
|
|
||||||
? 'complete'
|
|
||||||
: 'partial',
|
|
||||||
fetchedAt: discovery.meta.lastSuccessfulAt
|
|
||||||
? new Date(discovery.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: Number.MAX_SAFE_INTEGER,
|
|
||||||
work: discovery.meta.sync.id
|
|
||||||
? {
|
|
||||||
id: discovery.meta.sync.id,
|
|
||||||
state: discovery.meta.sync.state,
|
|
||||||
errorCode: discovery.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async detail(
|
async detail(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (!observe) {
|
|
||||||
const instrument = await this.access.online(userId, ({ token }) =>
|
const instrument = await this.access.online(userId, ({ token }) =>
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
? this.tbank.getShare(token, uid)
|
? this.tbank.getShare(token, uid)
|
||||||
: this.tbank.getBond(token, uid),
|
: this.tbank.getBond(token, uid),
|
||||||
);
|
);
|
||||||
if (!instrument) throw new NotFoundException();
|
// RUB-only: a non-RUB instrument line (e.g. USD line of an SPB-traded stock) is not available.
|
||||||
|
if (!instrument || instrument.currency?.toUpperCase() !== 'RUB') throw new NotFoundException();
|
||||||
return {
|
return {
|
||||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
@@ -140,76 +63,30 @@ export class CatalogDiscoveryService {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const sources = await this.enrichment.readMany(
|
|
||||||
userId,
|
|
||||||
instrumentType,
|
|
||||||
[uid],
|
|
||||||
observe,
|
|
||||||
['detail'],
|
|
||||||
);
|
|
||||||
const source = sources[0];
|
|
||||||
if (source.meta.completeness === 'complete' && !source.instrument) {
|
|
||||||
throw new NotFoundException();
|
|
||||||
}
|
|
||||||
const instrument = source.instrument;
|
|
||||||
let slug = uid;
|
|
||||||
const dependencies = [source.meta];
|
|
||||||
if (
|
|
||||||
instrument?.ticker &&
|
|
||||||
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
|
|
||||||
) {
|
|
||||||
const alias = await this.read(userId, {
|
|
||||||
instrumentType,
|
|
||||||
query: instrument.ticker,
|
|
||||||
exactTicker: true,
|
|
||||||
});
|
|
||||||
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
|
|
||||||
slug = instrument.ticker.toUpperCase();
|
|
||||||
}
|
|
||||||
dependencies.push(alias.meta);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
data: instrument ? { ...instrument, uid, slug } : null,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
|
||||||
dataRevision: dependencies
|
|
||||||
.map((dependency) => dependency.dataRevision)
|
|
||||||
.join('/'),
|
|
||||||
availability: source.meta.availability,
|
|
||||||
fetchedAt: source.meta.lastSuccessfulAt
|
|
||||||
? new Date(source.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: tbankSourceTtlMs.portfolio,
|
|
||||||
work: source.meta.sync.id
|
|
||||||
? {
|
|
||||||
id: source.meta.sync.id,
|
|
||||||
state: source.meta.sync.state,
|
|
||||||
errorCode: source.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
|
||||||
async byTicker(
|
async byTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (!observe) {
|
|
||||||
const query = ticker.trim().toUpperCase();
|
const query = ticker.trim().toUpperCase();
|
||||||
const found = await this.access.online(userId, ({ token }) =>
|
const found = await this.access.online(userId, ({ token }) =>
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
? this.tbank.findShares(token, query)
|
? this.tbank.findShares(token, query)
|
||||||
: this.tbank.findBonds(token, query),
|
: this.tbank.findBonds(token, query),
|
||||||
);
|
);
|
||||||
const matches = selectCatalogInstruments(
|
const candidates = selectCatalogInstruments(
|
||||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||||
query,
|
query,
|
||||||
instrumentType,
|
instrumentType,
|
||||||
|
);
|
||||||
|
const details = await this.access.online(userId, ({ token }) => Promise.all(
|
||||||
|
candidates.map((item) => instrumentType === 'share'
|
||||||
|
? this.tbank.getShare(token, item.uid)
|
||||||
|
: this.tbank.getBond(token, item.uid)),
|
||||||
|
));
|
||||||
|
const matches = candidates.filter((_item, index) =>
|
||||||
|
details.at(index)?.currency?.trim().toUpperCase() === 'RUB',
|
||||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||||
if (!matches.length) throw new NotFoundException();
|
if (!matches.length) throw new NotFoundException();
|
||||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||||
@@ -223,49 +100,6 @@ export class CatalogDiscoveryService {
|
|||||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||||
}
|
}
|
||||||
const lookup = await this.read(userId, {
|
|
||||||
instrumentType,
|
|
||||||
query: ticker,
|
|
||||||
exactTicker: true,
|
|
||||||
});
|
|
||||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
|
||||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
|
||||||
throw new ConflictException({
|
|
||||||
code:
|
|
||||||
instrumentType === 'share'
|
|
||||||
? InstrumentErrorCode.StockTickerAmbiguous
|
|
||||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const result = lookup.data
|
|
||||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
|
||||||
: null;
|
|
||||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
|
||||||
return {
|
|
||||||
data: result?.data
|
|
||||||
? { ...result.data, slug: ticker.trim().toUpperCase() }
|
|
||||||
: null,
|
|
||||||
meta: investmentResourceMeta({
|
|
||||||
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
|
|
||||||
dataRevision: dependencies
|
|
||||||
.map((dependency) => dependency.dataRevision)
|
|
||||||
.join('/'),
|
|
||||||
availability: result?.meta.availability ?? 'missing',
|
|
||||||
fetchedAt: lookup.meta.lastSuccessfulAt
|
|
||||||
? new Date(lookup.meta.lastSuccessfulAt)
|
|
||||||
: null,
|
|
||||||
freshForMs: tbankSourceTtlMs.portfolio,
|
|
||||||
work: lookup.meta.sync.id
|
|
||||||
? {
|
|
||||||
id: lookup.meta.sync.id,
|
|
||||||
state: lookup.meta.sync.state,
|
|
||||||
errorCode: lookup.meta.sync.errorCode,
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
dependencies,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||||
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
const { details, instruments, prices } = await this.access.online(userId, async ({ token }) => {
|
||||||
@@ -279,7 +113,7 @@ export class CatalogDiscoveryService {
|
|||||||
: found,
|
: found,
|
||||||
input.query,
|
input.query,
|
||||||
input.instrumentType,
|
input.instrumentType,
|
||||||
).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
);
|
||||||
const [details, prices] = await Promise.all([
|
const [details, prices] = await Promise.all([
|
||||||
Promise.all(
|
Promise.all(
|
||||||
instruments.map((item) =>
|
instruments.map((item) =>
|
||||||
@@ -297,12 +131,13 @@ export class CatalogDiscoveryService {
|
|||||||
const fetchedAt = new Date();
|
const fetchedAt = new Date();
|
||||||
const data = instruments.flatMap((item, index) => {
|
const data = instruments.flatMap((item, index) => {
|
||||||
const detail = details.at(index);
|
const detail = details.at(index);
|
||||||
|
if (detail?.currency?.trim().toUpperCase() !== 'RUB') return [];
|
||||||
return [{
|
return [{
|
||||||
...item,
|
...item,
|
||||||
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
detail: detail ? publicInstrument({ ...detail, uid: item.uid }) : null,
|
||||||
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
price: prices.find((price) => price.instrumentUid === item.uid) ?? null,
|
||||||
}];
|
}];
|
||||||
});
|
}).slice(FIRST_RESULT_OFFSET, input.exactTicker ? tickerAmbiguityLimit : catalogSearchLimit);
|
||||||
return {
|
return {
|
||||||
data,
|
data,
|
||||||
meta: investmentResourceMeta({
|
meta: investmentResourceMeta({
|
||||||
|
|||||||
@@ -1,43 +1,26 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
decimalFromString,
|
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||||
divide,
|
|
||||||
} from '../../../../common/decimal';
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
|
||||||
import {
|
|
||||||
MILLISECONDS_PER_DAY,
|
|
||||||
MILLISECONDS_PER_SECOND,
|
|
||||||
} from '../../../../common/time';
|
|
||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
|
||||||
type TbankCandle,
|
|
||||||
TbankClient,
|
TbankClient,
|
||||||
type TbankCoupon,
|
TbankLastPrice,
|
||||||
type TbankDividend,
|
|
||||||
type TbankFundamentals,
|
|
||||||
type TbankLastPrice,
|
|
||||||
type TbankShare,
|
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
catalogScope,
|
catalogScope,
|
||||||
type CatalogSource,
|
type CatalogSource,
|
||||||
} from '../infrastructure/catalog-discovery.repository';
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader';
|
||||||
|
export type { CatalogPayload } from './catalog-source.loader';
|
||||||
import { catalogSearchLimit } from './catalog-selection';
|
import { catalogSearchLimit } from './catalog-selection';
|
||||||
import { previousClose, timestamp } from './instrument-market';
|
|
||||||
import { publicInstrument } from './public-instrument';
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
const MISSING_DATA_REVISION = 0;
|
const MISSING_DATA_REVISION = 0;
|
||||||
@@ -93,9 +76,7 @@ export class CatalogEnrichmentService {
|
|||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly tbank: TbankClient,
|
private readonly tbank: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
private readonly moex: MoexClient,
|
private readonly loader: CatalogSourceLoader,
|
||||||
private readonly ratings: NcrRatingsClient,
|
|
||||||
private readonly candles: CandleRepository,
|
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async readMany(
|
async readMany(
|
||||||
@@ -110,6 +91,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const { revision } = await this.access.local(userId);
|
const { revision } = await this.access.local(userId);
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
@@ -123,6 +105,7 @@ export class CatalogEnrichmentService {
|
|||||||
false,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
range,
|
range,
|
||||||
|
refreshBefore,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const work = inputs.length
|
const work = inputs.length
|
||||||
@@ -175,6 +158,7 @@ export class CatalogEnrichmentService {
|
|||||||
tickers?: Record<string, string>;
|
tickers?: Record<string, string>;
|
||||||
to: string;
|
to: string;
|
||||||
},
|
},
|
||||||
|
refreshBefore?: Date,
|
||||||
) {
|
) {
|
||||||
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
const inputs = sourceInputs(instrumentType, uids, kinds, range);
|
||||||
const reservation = this.reservations.then(async () => {
|
const reservation = this.reservations.then(async () => {
|
||||||
@@ -189,7 +173,9 @@ export class CatalogEnrichmentService {
|
|||||||
);
|
);
|
||||||
const policy = sourcePolicy(input.kind);
|
const policy = sourcePolicy(input.kind);
|
||||||
const needsRefresh = force || !row?.fetchedAt ||
|
const needsRefresh = force || !row?.fetchedAt ||
|
||||||
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date()));
|
(policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) ||
|
||||||
|
// A walk reads a saved calendar once: later accounts see the new fetchedAt.
|
||||||
|
(policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore);
|
||||||
if (!needsRefresh) {
|
if (!needsRefresh) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -236,9 +222,8 @@ export class CatalogEnrichmentService {
|
|||||||
instrumentType: 'bond' | 'share',
|
instrumentType: 'bond' | 'share',
|
||||||
uid: string,
|
uid: string,
|
||||||
section: 'company' | 'metrics' | 'quote',
|
section: 'company' | 'metrics' | 'quote',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
if (section === 'company' && !observe) {
|
if (section === 'company') {
|
||||||
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
|
||||||
const instrument =
|
const instrument =
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
@@ -268,10 +253,7 @@ export class CatalogEnrichmentService {
|
|||||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const kinds: CatalogSource['kind'][] =
|
const kinds: CatalogSource['kind'][] = [
|
||||||
section === 'company'
|
|
||||||
? ['detail', 'description', 'fundamentals']
|
|
||||||
: [
|
|
||||||
...(instrumentType === 'bond'
|
...(instrumentType === 'bond'
|
||||||
? ['bond-current' as const]
|
? ['bond-current' as const]
|
||||||
: ['detail' as const]),
|
: ['detail' as const]),
|
||||||
@@ -286,7 +268,7 @@ export class CatalogEnrichmentService {
|
|||||||
userId,
|
userId,
|
||||||
instrumentType,
|
instrumentType,
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
kinds,
|
kinds,
|
||||||
);
|
);
|
||||||
const first = sources[0];
|
const first = sources[0];
|
||||||
@@ -314,7 +296,7 @@ export class CatalogEnrichmentService {
|
|||||||
fetchedAt: first.meta.lastSuccessfulAt
|
fetchedAt: first.meta.lastSuccessfulAt
|
||||||
? new Date(first.meta.lastSuccessfulAt)
|
? new Date(first.meta.lastSuccessfulAt)
|
||||||
: null,
|
: null,
|
||||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
freshForMs: tbankSourceTtlMs.quote,
|
||||||
work: null,
|
work: null,
|
||||||
dependencies,
|
dependencies,
|
||||||
});
|
});
|
||||||
@@ -332,169 +314,6 @@ export class CatalogEnrichmentService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
|
||||||
private async load(
|
|
||||||
input: CatalogSource,
|
|
||||||
token: string,
|
|
||||||
attempt: TbankSyncAttempt,
|
|
||||||
): Promise<CatalogPayload> {
|
|
||||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
|
||||||
const instrument =
|
|
||||||
input.instrumentType === 'share'
|
|
||||||
? await this.tbank.getShare(token, input.uid)
|
|
||||||
: await this.tbank.getBond(token, input.uid);
|
|
||||||
if (instrument && instrument.uid !== input.uid) {
|
|
||||||
throw new Error('Catalog UID mismatch');
|
|
||||||
}
|
|
||||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
|
||||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
|
||||||
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
|
|
||||||
}
|
|
||||||
if (input.kind === 'trading-status') {
|
|
||||||
return {
|
|
||||||
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.kind === 'comparison') {
|
|
||||||
const prices = await this.repository.readSources([
|
|
||||||
{ ...input, kind: 'last-price', rangeKey: undefined },
|
|
||||||
]);
|
|
||||||
if (!prices.length) throw new Error('Price dependency missing');
|
|
||||||
const price = (prices[0].payload as CatalogPayload).price;
|
|
||||||
const priceAt = timestamp(price?.time);
|
|
||||||
if (!priceAt) return { candle: null };
|
|
||||||
const now = new Date();
|
|
||||||
const range = {
|
|
||||||
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
|
|
||||||
to: new Date(priceAt),
|
|
||||||
};
|
|
||||||
const saved = await this.candles.read(input.uid, range);
|
|
||||||
const gaps = candleGaps(range, saved.coverage, now);
|
|
||||||
for (const gap of gaps) {
|
|
||||||
const response = await this.tbank.getCandles(
|
|
||||||
token,
|
|
||||||
input.uid,
|
|
||||||
gap.from,
|
|
||||||
gap.to,
|
|
||||||
);
|
|
||||||
const rows = response.map(toSavedCandle);
|
|
||||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
|
||||||
throw new Error('Cancelled candle comparison');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
gaps.length &&
|
|
||||||
!(await this.candles.publish(
|
|
||||||
attempt,
|
|
||||||
{ uid: input.uid, ranges: gaps },
|
|
||||||
false,
|
|
||||||
))
|
|
||||||
) {
|
|
||||||
throw new Error('Cancelled candle comparison');
|
|
||||||
}
|
|
||||||
const values = (await this.candles.read(input.uid, range)).data.map(
|
|
||||||
(row) => {
|
|
||||||
const value = divide(
|
|
||||||
decimalFromString(row.close),
|
|
||||||
decimalFromString('1'),
|
|
||||||
quotationScale,
|
|
||||||
).value;
|
|
||||||
return {
|
|
||||||
time: {
|
|
||||||
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
|
|
||||||
},
|
|
||||||
close: {
|
|
||||||
units: String(value / quotationUnits),
|
|
||||||
nano: Number(value % quotationUnits),
|
|
||||||
},
|
|
||||||
isComplete: row.isComplete,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
);
|
|
||||||
return { candle: previousClose(values, priceAt) };
|
|
||||||
}
|
|
||||||
const details = await this.repository.readSources([
|
|
||||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
|
||||||
...(input.instrumentType === 'bond'
|
|
||||||
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
|
||||||
: []),
|
|
||||||
]);
|
|
||||||
const instrument = details
|
|
||||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
|
||||||
.find(Boolean);
|
|
||||||
if (
|
|
||||||
!instrument &&
|
|
||||||
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
|
|
||||||
) {
|
|
||||||
throw new Error('Instrument dependency missing');
|
|
||||||
}
|
|
||||||
if (input.kind === 'description') {
|
|
||||||
return {
|
|
||||||
description: instrument?.assetUid
|
|
||||||
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.kind === 'fundamentals') {
|
|
||||||
return {
|
|
||||||
fundamentals: instrument?.assetUid
|
|
||||||
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
|
||||||
const year = Number(input.rangeKey);
|
|
||||||
const [from, to] = input.rangeKey?.startsWith('[')
|
|
||||||
? (JSON.parse(input.rangeKey) as [string, string])
|
|
||||||
: [
|
|
||||||
`${year - payoutHistoryYears}-01-01`,
|
|
||||||
`${year + payoutFutureYears}-01-01`,
|
|
||||||
];
|
|
||||||
return input.kind === 'coupons'
|
|
||||||
? {
|
|
||||||
coupons: await this.tbank.getBondCoupons(
|
|
||||||
token,
|
|
||||||
input.uid,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
dividends: await this.tbank.getDividends(
|
|
||||||
token,
|
|
||||||
input.uid,
|
|
||||||
from,
|
|
||||||
to,
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.kind === 'bond-events') {
|
|
||||||
const ticker = instrument?.ticker || input.ticker;
|
|
||||||
if (!ticker) throw new Error('Bond ticker missing');
|
|
||||||
return { bondEvents: await this.moex.bondEvents(ticker) };
|
|
||||||
}
|
|
||||||
if (input.kind === 'yield') {
|
|
||||||
return {
|
|
||||||
effectiveYield:
|
|
||||||
instrument?.ticker && instrument.isin && instrument.classCode
|
|
||||||
? await this.moex.bondYield(
|
|
||||||
instrument.ticker,
|
|
||||||
instrument.isin,
|
|
||||||
instrument.classCode,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (input.kind === 'rating') {
|
|
||||||
return {
|
|
||||||
creditRating: instrument?.isin
|
|
||||||
? await this.ratings.rating(instrument.isin)
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
throw new Error('Unsupported catalog source');
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Queue draining coordinates publication fences and observer completion.
|
||||||
private async drain() {
|
private async drain() {
|
||||||
try {
|
try {
|
||||||
@@ -532,7 +351,7 @@ export class CatalogEnrichmentService {
|
|||||||
batch.map((item) => item.input.uid),
|
batch.map((item) => item.input.uid),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
payload = await this.load(producer.input, token, producer.attempt);
|
payload = await this.loader.load(producer.input, token, producer.attempt);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
failed = true;
|
||||||
@@ -634,24 +453,6 @@ function sourceInputs(
|
|||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const previousCloseLookbackDays = 20;
|
|
||||||
const previousCloseLookbackMs =
|
|
||||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
|
||||||
const payoutHistoryYears = 5;
|
|
||||||
const payoutFutureYears = 2;
|
|
||||||
export type CatalogPayload = {
|
|
||||||
bondEvents?: MoexBondEvent[];
|
|
||||||
candle?: null | TbankCandle;
|
|
||||||
coupons?: TbankCoupon[];
|
|
||||||
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
|
||||||
description?: null | string;
|
|
||||||
dividends?: TbankDividend[];
|
|
||||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
|
||||||
fundamentals?: null | TbankFundamentals;
|
|
||||||
instrument?: null | TbankBond | TbankShare;
|
|
||||||
price?: null | TbankLastPrice;
|
|
||||||
tradingStatus?: null | string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function sourcePolicy(kind: CatalogSource['kind']) {
|
function sourcePolicy(kind: CatalogSource['kind']) {
|
||||||
if (
|
if (
|
||||||
@@ -665,6 +466,7 @@ function sourcePolicy(kind: CatalogSource['kind']) {
|
|||||||
) {
|
) {
|
||||||
return 'quote';
|
return 'quote';
|
||||||
}
|
}
|
||||||
|
if (['bond-events', 'dividends'].includes(kind)) return 'calendar';
|
||||||
return 'stable';
|
return 'stable';
|
||||||
}
|
}
|
||||||
function producerKey(input: CatalogSource) {
|
function producerKey(input: CatalogSource) {
|
||||||
@@ -673,6 +475,3 @@ function producerKey(input: CatalogSource) {
|
|||||||
? `bond-metadata:${input.uid}`
|
? `bond-metadata:${input.uid}`
|
||||||
: catalogScope(input);
|
: catalogScope(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotationScale = 9;
|
|
||||||
const quotationUnits = 1_000_000_000n;
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
|
||||||
|
|
||||||
export const catalogSearchLimit = 30;
|
export const catalogSearchLimit = 30;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import {
|
||||||
|
decimalFromString,
|
||||||
|
divide,
|
||||||
|
} from '#src/common/decimal';
|
||||||
|
import {
|
||||||
|
MILLISECONDS_PER_DAY,
|
||||||
|
MILLISECONDS_PER_SECOND,
|
||||||
|
} from '#src/common/time';
|
||||||
|
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
|
||||||
|
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
|
||||||
|
import {
|
||||||
|
type TbankBond,
|
||||||
|
type TbankCandle,
|
||||||
|
TbankClient,
|
||||||
|
type TbankCoupon,
|
||||||
|
type TbankDividend,
|
||||||
|
type TbankFundamentals,
|
||||||
|
type TbankLastPrice,
|
||||||
|
type TbankShare,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||||
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
|
import {
|
||||||
|
CatalogDiscoveryRepository,
|
||||||
|
type CatalogSource,
|
||||||
|
} from '../infrastructure/catalog-discovery.repository';
|
||||||
|
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||||
|
import { previousClose, timestamp } from './instrument-market';
|
||||||
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
|
export type CatalogPayload = {
|
||||||
|
bondEvents?: MoexBondEvent[];
|
||||||
|
candle?: null | TbankCandle;
|
||||||
|
coupons?: TbankCoupon[];
|
||||||
|
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||||
|
description?: null | string;
|
||||||
|
dividends?: TbankDividend[];
|
||||||
|
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||||
|
fundamentals?: null | TbankFundamentals;
|
||||||
|
instrument?: null | TbankBond | TbankShare;
|
||||||
|
price?: null | TbankLastPrice;
|
||||||
|
tradingStatus?: null | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const previousCloseLookbackDays = 20;
|
||||||
|
const previousCloseLookbackMs =
|
||||||
|
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||||
|
const payoutHistoryYears = 5;
|
||||||
|
const payoutFutureYears = 2;
|
||||||
|
const quotationScale = 9;
|
||||||
|
const quotationUnits = 1_000_000_000n;
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class CatalogSourceLoader {
|
||||||
|
constructor(
|
||||||
|
private readonly repository: CatalogDiscoveryRepository,
|
||||||
|
private readonly tbank: TbankClient,
|
||||||
|
private readonly moex: MoexClient,
|
||||||
|
private readonly ratings: NcrRatingsClient,
|
||||||
|
private readonly candles: CandleRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||||
|
async load(
|
||||||
|
input: CatalogSource,
|
||||||
|
token: string,
|
||||||
|
attempt: TbankSyncAttempt,
|
||||||
|
): Promise<CatalogPayload> {
|
||||||
|
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||||
|
const instrument =
|
||||||
|
input.instrumentType === 'share'
|
||||||
|
? await this.tbank.getShare(token, input.uid)
|
||||||
|
: await this.tbank.getBond(token, input.uid);
|
||||||
|
if (instrument && instrument.uid !== input.uid) {
|
||||||
|
throw new Error('Catalog UID mismatch');
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||||
|
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||||
|
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
|
||||||
|
}
|
||||||
|
if (input.kind === 'trading-status') {
|
||||||
|
return {
|
||||||
|
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'comparison') {
|
||||||
|
const prices = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||||
|
]);
|
||||||
|
if (!prices.length) throw new Error('Price dependency missing');
|
||||||
|
const price = (prices[0].payload as CatalogPayload).price;
|
||||||
|
const priceAt = timestamp(price?.time);
|
||||||
|
if (!priceAt) return { candle: null };
|
||||||
|
const now = new Date();
|
||||||
|
const range = {
|
||||||
|
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
|
||||||
|
to: new Date(priceAt),
|
||||||
|
};
|
||||||
|
const saved = await this.candles.read(input.uid, range);
|
||||||
|
const gaps = candleGaps(range, saved.coverage, now);
|
||||||
|
for (const gap of gaps) {
|
||||||
|
const response = await this.tbank.getCandles(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
gap.from,
|
||||||
|
gap.to,
|
||||||
|
);
|
||||||
|
const rows = response.map(toSavedCandle);
|
||||||
|
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
gaps.length &&
|
||||||
|
!(await this.candles.publish(
|
||||||
|
attempt,
|
||||||
|
{ uid: input.uid, ranges: gaps },
|
||||||
|
false,
|
||||||
|
))
|
||||||
|
) {
|
||||||
|
throw new Error('Cancelled candle comparison');
|
||||||
|
}
|
||||||
|
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||||
|
(row) => {
|
||||||
|
const value = divide(
|
||||||
|
decimalFromString(row.close),
|
||||||
|
decimalFromString('1'),
|
||||||
|
quotationScale,
|
||||||
|
).value;
|
||||||
|
return {
|
||||||
|
time: {
|
||||||
|
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
|
||||||
|
},
|
||||||
|
close: {
|
||||||
|
units: String(value / quotationUnits),
|
||||||
|
nano: Number(value % quotationUnits),
|
||||||
|
},
|
||||||
|
isComplete: row.isComplete,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { candle: previousClose(values, priceAt) };
|
||||||
|
}
|
||||||
|
const details = await this.repository.readSources([
|
||||||
|
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||||
|
...(input.instrumentType === 'bond'
|
||||||
|
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||||
|
: []),
|
||||||
|
]);
|
||||||
|
const instrument = details
|
||||||
|
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||||
|
.find(Boolean);
|
||||||
|
if (
|
||||||
|
!instrument &&
|
||||||
|
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
|
||||||
|
) {
|
||||||
|
throw new Error('Instrument dependency missing');
|
||||||
|
}
|
||||||
|
if (input.kind === 'description') {
|
||||||
|
return {
|
||||||
|
description: instrument?.assetUid
|
||||||
|
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'fundamentals') {
|
||||||
|
return {
|
||||||
|
fundamentals: instrument?.assetUid
|
||||||
|
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||||
|
const year = Number(input.rangeKey);
|
||||||
|
const [from, to] = input.rangeKey?.startsWith('[')
|
||||||
|
? (JSON.parse(input.rangeKey) as [string, string])
|
||||||
|
: [
|
||||||
|
`${year - payoutHistoryYears}-01-01`,
|
||||||
|
`${year + payoutFutureYears}-01-01`,
|
||||||
|
];
|
||||||
|
return input.kind === 'coupons'
|
||||||
|
? {
|
||||||
|
coupons: await this.tbank.getBondCoupons(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
dividends: await this.tbank.getDividends(
|
||||||
|
token,
|
||||||
|
input.uid,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'bond-events') {
|
||||||
|
const ticker = instrument?.ticker || input.ticker;
|
||||||
|
if (!ticker) throw new Error('Bond ticker missing');
|
||||||
|
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||||
|
}
|
||||||
|
if (input.kind === 'yield') {
|
||||||
|
return {
|
||||||
|
effectiveYield:
|
||||||
|
instrument?.ticker && instrument.isin && instrument.classCode
|
||||||
|
? await this.moex.bondYield(
|
||||||
|
instrument.ticker,
|
||||||
|
instrument.isin,
|
||||||
|
instrument.classCode,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (input.kind === 'rating') {
|
||||||
|
return {
|
||||||
|
creditRating: instrument?.isin
|
||||||
|
? await this.ratings.rating(instrument.isin)
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error('Unsupported catalog source');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import type {
|
import type {
|
||||||
TbankCandle,
|
TbankCandle,
|
||||||
TbankTimestamp,
|
TbankTimestamp,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||||
|
|
||||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||||
|
|
||||||
|
|||||||
@@ -22,14 +22,16 @@ const row = (
|
|||||||
isin: null,
|
isin: null,
|
||||||
ticker: null,
|
ticker: null,
|
||||||
logoName: null,
|
logoName: null,
|
||||||
|
sector: null,
|
||||||
|
issuer: null,
|
||||||
});
|
});
|
||||||
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
const portfolio = (positions: PortfolioPositionDto[]): PortfolioDto => ({
|
||||||
positions,
|
positions,
|
||||||
cashBalances: [],
|
cash: { currency: 'RUB', value: '0' },
|
||||||
totalsByCurrency: [],
|
totals: { currency: 'RUB', value: '0' },
|
||||||
isStale: false,
|
isStale: false,
|
||||||
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
lastSuccessfulAt: '2026-09-10T00:00:00Z',
|
||||||
structure: { currencies: [], unvaluedCategories: [] },
|
structure: { totalValue: '0', hasNegativeValues: false, categories: [], unvaluedCategories: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('stock position', () => {
|
describe('stock position', () => {
|
||||||
@@ -62,10 +64,10 @@ describe('stock position', () => {
|
|||||||
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
expect(result.averagePurchasePrice?.value).toBe('0.1');
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
it('does not sum currencies or treat unknown values as zero', () => {
|
it('does not treat unknown values as zero', () => {
|
||||||
const second = {
|
const second = {
|
||||||
...row(),
|
...row(),
|
||||||
currentValue: money('2', 'USD'),
|
currentValue: null,
|
||||||
averagePurchasePrice: null,
|
averagePurchasePrice: null,
|
||||||
absoluteReturn: null,
|
absoluteReturn: null,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -4,8 +4,9 @@ import {
|
|||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
PortfolioDto,
|
PortfolioDto,
|
||||||
PortfolioPositionDto,
|
PortfolioPositionDto,
|
||||||
@@ -45,7 +46,7 @@ export function instrumentPosition(
|
|||||||
averagePurchasePrice:
|
averagePurchasePrice:
|
||||||
positive && totalCost
|
positive && totalCost
|
||||||
? {
|
? {
|
||||||
currency: totalCost.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
decimalFromString(totalCost.value),
|
decimalFromString(totalCost.value),
|
||||||
@@ -82,17 +83,12 @@ function cost(position: PortfolioPositionDto): MoneyDto | null {
|
|||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
function sumComplete(values: (MoneyDto | null)[]): MoneyDto | null {
|
||||||
const currency = values[0]?.currency;
|
if (!values.length || !values.every((value): value is MoneyDto => value !== null)) return null;
|
||||||
if (
|
const present = values;
|
||||||
!currency ||
|
|
||||||
!values.every((v): v is MoneyDto => v !== null && v.currency === currency)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
values.reduce(
|
present.reduce(
|
||||||
(sum, value) => add(sum, decimalFromString(value.value)),
|
(sum, value) => add(sum, decimalFromString(value.value)),
|
||||||
decimalFromString('0'),
|
decimalFromString('0'),
|
||||||
),
|
),
|
||||||
@@ -106,7 +102,6 @@ function percent(
|
|||||||
if (
|
if (
|
||||||
!base ||
|
!base ||
|
||||||
!result ||
|
!result ||
|
||||||
base.currency !== result.currency ||
|
|
||||||
decimalFromString(base.value).value <= ZERO_AMOUNT
|
decimalFromString(base.value).value <= ZERO_AMOUNT
|
||||||
) {
|
) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
TbankBond,
|
TbankBond,
|
||||||
TbankShare,
|
TbankShare,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { publicInstrument } from './public-instrument';
|
import { publicInstrument } from './public-instrument';
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type {
|
import type {
|
||||||
TbankBond,
|
TbankBond,
|
||||||
TbankShare,
|
TbankShare,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
export function publicInstrument(value: TbankBond | TbankShare) {
|
export function publicInstrument(value: TbankBond | TbankShare) {
|
||||||
const bond = value as TbankBond;
|
const bond = value as TbankBond;
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { formatDecimal } from '#src/common/decimal';
|
||||||
|
import { decimal } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
import { timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
@@ -14,7 +15,8 @@ import {
|
|||||||
|
|
||||||
type IdentifiedBond = TbankBond & { uid: string };
|
type IdentifiedBond = TbankBond & { uid: string };
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
|
|
||||||
import { money } from '../infrastructure/bond-money.mapper';
|
import { money } from '../infrastructure/bond-money.mapper';
|
||||||
|
|
||||||
const MIN_LOT_EXCLUSIVE = 0;
|
const MIN_LOT_EXCLUSIVE = 0;
|
||||||
@@ -25,12 +27,10 @@ export class BondCatalogService {
|
|||||||
async search(
|
async search(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondSearchResourceDto> {
|
): Promise<BondSearchResourceDto> {
|
||||||
const result = await this.discovery.search(
|
const result = await this.discovery.search(
|
||||||
userId,
|
userId,
|
||||||
{ instrumentType: 'bond', query },
|
{ instrumentType: 'bond', query },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -58,9 +58,8 @@ export class BondCatalogService {
|
|||||||
async get(
|
async get(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondResourceDto> {
|
): Promise<BondResourceDto> {
|
||||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
const result = await this.discovery.detail(userId, 'bond', uid);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data
|
data: result.data
|
||||||
@@ -72,13 +71,11 @@ export class BondCatalogService {
|
|||||||
async getByTicker(
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondResourceDto> {
|
): Promise<BondResourceDto> {
|
||||||
const result = await this.discovery.byTicker(
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
ticker,
|
ticker,
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -5,19 +5,20 @@ import {
|
|||||||
divide,
|
divide,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
type TbankCoupon,
|
type TbankCoupon,
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
} from '../../../portfolio/portfolio.api';
|
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
type CatalogPayload,
|
type CatalogPayload,
|
||||||
@@ -54,14 +55,12 @@ export class BondIncomeService {
|
|||||||
async metrics(
|
async metrics(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondMetricsResourceDto> {
|
): Promise<BondMetricsResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
uid,
|
uid,
|
||||||
'metrics',
|
'metrics',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -82,7 +81,6 @@ export class BondIncomeService {
|
|||||||
async coupons(
|
async coupons(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondCouponsResourceDto> {
|
): Promise<BondCouponsResourceDto> {
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
@@ -93,7 +91,7 @@ export class BondIncomeService {
|
|||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
['detail', 'coupons'],
|
['detail', 'coupons'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
@@ -180,7 +178,7 @@ export function bondCoupons(
|
|||||||
recordDate &&
|
recordDate &&
|
||||||
recordDate >= today
|
recordDate >= today
|
||||||
? {
|
? {
|
||||||
currency: perBond.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perBond.value)),
|
multiply(quantity, decimalFromString(perBond.value)),
|
||||||
),
|
),
|
||||||
@@ -229,7 +227,6 @@ export function bondMetrics(
|
|||||||
coupon &&
|
coupon &&
|
||||||
couponPeriodDays &&
|
couponPeriodDays &&
|
||||||
clean &&
|
clean &&
|
||||||
coupon.currency === clean.currency &&
|
|
||||||
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
decimalFromString(coupon.value).value >= ZERO_AMOUNT &&
|
||||||
decimalFromString(clean.value).value > ZERO_AMOUNT
|
decimalFromString(clean.value).value > ZERO_AMOUNT
|
||||||
? formatDecimal(
|
? formatDecimal(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
add,
|
add,
|
||||||
@@ -9,9 +9,10 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { decimal } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import { CandleService } from '../../application/candle.service';
|
import { CandleService } from '../../application/candle.service';
|
||||||
import {
|
import {
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
@@ -38,14 +39,12 @@ export class BondMarketService {
|
|||||||
async quote(
|
async quote(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<BondQuoteResourceDto> {
|
): Promise<BondQuoteResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'bond',
|
'bond',
|
||||||
uid,
|
uid,
|
||||||
'quote',
|
'quote',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -59,12 +58,10 @@ export class BondMarketService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
period: BondCandlesQueryDto['period'] = 'month',
|
period: BondCandlesQueryDto['period'] = 'month',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
return this.history.read(
|
return this.history.read(
|
||||||
userId,
|
userId,
|
||||||
{ uid, instrumentType: 'bond', period },
|
{ uid, instrumentType: 'bond', period },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -85,7 +82,7 @@ export function bondQuote(
|
|||||||
nominal &&
|
nominal &&
|
||||||
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
decimalFromString(nominal.value).value > ZERO_AMOUNT
|
||||||
? {
|
? {
|
||||||
currency: nominal.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
divide(
|
divide(
|
||||||
multiply(
|
multiply(
|
||||||
@@ -99,9 +96,9 @@ export function bondQuote(
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
const dirtyPrice =
|
const dirtyPrice =
|
||||||
cleanPrice && aci && aci.currency === cleanPrice.currency
|
cleanPrice && aci
|
||||||
? {
|
? {
|
||||||
currency: cleanPrice.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
add(
|
add(
|
||||||
decimalFromString(cleanPrice.value),
|
decimalFromString(cleanPrice.value),
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||||
|
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||||
import { bondQuote } from './bond-market.service';
|
import { bondQuote } from './bond-market.service';
|
||||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
|
|||||||
@@ -1,10 +1 @@
|
|||||||
import { formatDecimal } from '../../../../../common/decimal';
|
export { rubMoney as money } from '#src/integrations/tbank/decimal';
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
export function money(value?: TbankMoney) {
|
|
||||||
const currency = value?.currency?.toUpperCase();
|
|
||||||
return value && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(value)) }
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -11,9 +11,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
|
||||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||||
import { BondIncomeService } from '../application/bond-income.service';
|
import { BondIncomeService } from '../application/bond-income.service';
|
||||||
import { BondMarketService } from '../application/bond-market.service';
|
import { BondMarketService } from '../application/bond-market.service';
|
||||||
@@ -47,9 +47,8 @@ export class BondController {
|
|||||||
metrics(
|
metrics(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
return this.income.metrics(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@@ -61,7 +60,6 @@ export class BondController {
|
|||||||
return this.bonds.search(
|
return this.bonds.search(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
query.query,
|
query.query,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,12 +68,10 @@ export class BondController {
|
|||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: BondTickerParamsDto,
|
@Param() params: BondTickerParamsDto,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.bonds.getByTicker(
|
return this.bonds.getByTicker(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
params.ticker,
|
params.ticker,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,9 +80,8 @@ export class BondController {
|
|||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
return this.bonds.get(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@@ -94,9 +89,8 @@ export class BondController {
|
|||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
return this.market.quote(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@@ -110,7 +104,6 @@ export class BondController {
|
|||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.period,
|
query.period,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,8 +121,7 @@ export class BondController {
|
|||||||
coupons(
|
coupons(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
return this.income.coupons(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
StockCandlesDto,
|
StockCandlesDto,
|
||||||
StockCandlesQueryDto,
|
StockCandlesQueryDto,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||||
|
|
||||||
const FIRST_RANGE_END_INDEX = 1;
|
const FIRST_RANGE_END_INDEX = 1;
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { createHash, randomUUID } from 'node:crypto';
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import type { Prisma } from '#generated/prisma/client';
|
||||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
import {
|
||||||
|
claimSyncWork,
|
||||||
|
lockConnectionGeneration,
|
||||||
|
} from '#src/integrations/tbank/tbank-sync-claim';
|
||||||
|
import {
|
||||||
|
isJoinableRunningAttempt,
|
||||||
|
type TbankSyncAttempt,
|
||||||
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
|
import { toJson } from '#src/prisma/to-json';
|
||||||
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
|
||||||
import {
|
import {
|
||||||
type CandleRange,
|
type CandleRange,
|
||||||
confirmedCandleCoverage,
|
confirmedCandleCoverage,
|
||||||
@@ -62,34 +71,30 @@ export class CandleRepository {
|
|||||||
priority = 1,
|
priority = 1,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey };
|
const scope = { ...owner, scopeKey };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
const joinable = isJoinableRunningAttempt(previous, runId, active)
|
||||||
previous?.state === 'running' &&
|
? previous
|
||||||
previous.processRunId === runId &&
|
: null;
|
||||||
active.has(previous.attemptId)
|
if (joinable) {
|
||||||
) {
|
const previousIntent = joinable.intent as Prisma.JsonObject;
|
||||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
|
||||||
const previousNow =
|
const previousNow =
|
||||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||||
const nextPriority = Math.min(previous.priority, priority);
|
const nextPriority = Math.min(joinable.priority, priority);
|
||||||
if (
|
if (
|
||||||
nextPriority === previous.priority &&
|
nextPriority === joinable.priority &&
|
||||||
(!intent.force ||
|
(!intent.force ||
|
||||||
(previousIntent.force === true &&
|
(previousIntent.force === true &&
|
||||||
(!intent.now || intent.now <= previousNow)))
|
(!intent.now || intent.now <= previousNow)))
|
||||||
) {
|
) {
|
||||||
return { work: previous, launch: false };
|
return { work: joinable, launch: false };
|
||||||
}
|
}
|
||||||
const work = await tx.tbankSyncWork.update({
|
const work = await tx.tbankSyncWork.update({
|
||||||
where: { id: previous.id },
|
where: { id: joinable.id },
|
||||||
data: {
|
data: {
|
||||||
priority: nextPriority,
|
priority: nextPriority,
|
||||||
intent: {
|
intent: {
|
||||||
@@ -115,7 +120,7 @@ export class CandleRepository {
|
|||||||
processRunId: runId,
|
processRunId: runId,
|
||||||
state: 'running',
|
state: 'running',
|
||||||
priority,
|
priority,
|
||||||
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
intent: toJson(intent),
|
||||||
progress: {},
|
progress: {},
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
};
|
};
|
||||||
@@ -163,24 +168,17 @@ export class CandleRepository {
|
|||||||
).values(),
|
).values(),
|
||||||
];
|
];
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const valid = await tx.tbankSyncWork.updateMany({
|
const valid = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
progress: { lastWindow: key },
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { progress: { lastWindow: key } },
|
|
||||||
});
|
});
|
||||||
if (!valid.count) return false;
|
if (!valid) return false;
|
||||||
const identity = {
|
const identity = {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
attemptId: attempt.attemptId,
|
attemptId: attempt.attemptId,
|
||||||
sourceKey: 'candles',
|
sourceKey: 'candles',
|
||||||
itemKey: key,
|
itemKey: key,
|
||||||
};
|
};
|
||||||
const payload = JSON.parse(
|
const payload = toJson({ range, candles: rows, fetchedAt });
|
||||||
JSON.stringify({ range, candles: rows, fetchedAt }),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankSyncStage.upsert({
|
await tx.tbankSyncStage.upsert({
|
||||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||||
create: { ...identity, payload },
|
create: { ...identity, payload },
|
||||||
@@ -196,18 +194,11 @@ export class CandleRepository {
|
|||||||
completeWork = true,
|
completeWork = true,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
...(completeWork ? { state: 'complete' } : {}),
|
...(completeWork ? { state: 'complete' } : {}),
|
||||||
errorCode: null,
|
errorCode: null,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
const staged = await tx.tbankSyncStage.findMany({
|
const staged = await tx.tbankSyncStage.findMany({
|
||||||
where: {
|
where: {
|
||||||
workId: attempt.id,
|
workId: attempt.id,
|
||||||
@@ -311,28 +302,17 @@ export class CandleRepository {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!origin) return false;
|
if (!origin) return false;
|
||||||
const result = await tx.tbankSyncWork.updateMany({
|
const result = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
errorCode: null,
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', errorCode: null },
|
|
||||||
});
|
});
|
||||||
return result.count > NO_UPDATED_ROWS;
|
return result > NO_UPDATED_ROWS;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, { state: 'failed', errorCode });
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'failed', errorCode },
|
|
||||||
});
|
|
||||||
await tx.tbankSyncStage.deleteMany({
|
await tx.tbankSyncStage.deleteMany({
|
||||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||||
});
|
});
|
||||||
|
|||||||
+30
-59
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { isDeepStrictEqual } from 'node:util';
|
import { isDeepStrictEqual } from 'node:util';
|
||||||
|
|
||||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||||
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||||
|
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
|
||||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { toJson } from '#src/prisma/to-json';
|
||||||
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
const REVISION_INCREMENT = 1;
|
const REVISION_INCREMENT = 1;
|
||||||
@@ -159,27 +159,19 @@ export class CatalogDiscoveryRepository {
|
|||||||
initiator: TbankSyncAttempt = attempt,
|
initiator: TbankSyncAttempt = attempt,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const origin = await tx.tbankSyncWork.updateMany({
|
const origin = await claimSyncWork(
|
||||||
where: {
|
tx,
|
||||||
...initiator,
|
initiator,
|
||||||
state: { in: ['running', 'complete'] },
|
{ progress: {} },
|
||||||
connection: { invalidToken: false },
|
{ state: { in: ['running', 'complete'] } },
|
||||||
},
|
);
|
||||||
data: { progress: {} },
|
if (!origin) return false;
|
||||||
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
|
state: 'complete',
|
||||||
|
progress: {},
|
||||||
});
|
});
|
||||||
if (!origin.count) return false;
|
if (!claimed) return false;
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const payload = toJson(value);
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
|
||||||
if (!claimed.count) return false;
|
|
||||||
const payload = JSON.parse(
|
|
||||||
JSON.stringify(value),
|
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
const identity = { provider, uid: input.uid };
|
const identity = { provider, uid: input.uid };
|
||||||
await tx.tbankInstrument.upsert({
|
await tx.tbankInstrument.upsert({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
@@ -202,12 +194,10 @@ export class CatalogDiscoveryRepository {
|
|||||||
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
});
|
});
|
||||||
const metadata = JSON.parse(
|
const metadata = toJson({
|
||||||
JSON.stringify({
|
|
||||||
...instrument,
|
...instrument,
|
||||||
instrumentType: input.instrumentType,
|
instrumentType: input.instrumentType,
|
||||||
}),
|
});
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankInstrument.update({
|
await tx.tbankInstrument.update({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
data: {
|
data: {
|
||||||
@@ -284,20 +274,13 @@ export class CatalogDiscoveryRepository {
|
|||||||
active: ReadonlySet<string>,
|
active: ReadonlySet<string>,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const current = await tx.tbankConnection.updateMany({
|
const current = await lockConnectionGeneration(tx, owner);
|
||||||
where: { ...owner, invalidToken: false },
|
if (!current) return null;
|
||||||
data: { membershipRevision: { increment: 0 } },
|
|
||||||
});
|
|
||||||
if (!current.count) return null;
|
|
||||||
const scope = { ...owner, scopeKey: catalogScope(input) };
|
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||||
const previous = await tx.tbankSyncWork.findUnique({
|
const previous = await tx.tbankSyncWork.findUnique({
|
||||||
where: { userId_revision_scopeKey: scope },
|
where: { userId_revision_scopeKey: scope },
|
||||||
});
|
});
|
||||||
if (
|
if (isJoinableRunningAttempt(previous, runId, active)) {
|
||||||
previous?.state === 'running' &&
|
|
||||||
previous.processRunId === runId &&
|
|
||||||
active.has(previous.attemptId)
|
|
||||||
) {
|
|
||||||
return { work: previous, launch: false };
|
return { work: previous, launch: false };
|
||||||
}
|
}
|
||||||
const data = {
|
const data = {
|
||||||
@@ -345,20 +328,15 @@ export class CatalogDiscoveryRepository {
|
|||||||
now: Date,
|
now: Date,
|
||||||
) {
|
) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
state: 'complete',
|
||||||
...attempt,
|
progress: {},
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: { state: 'complete', progress: {} },
|
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed) return false;
|
||||||
for (const instrument of instruments) {
|
for (const instrument of instruments) {
|
||||||
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||||
const identity = { provider, uid: instrument.uid };
|
const identity = { provider, uid: instrument.uid };
|
||||||
const payload = JSON.parse(
|
const payload = toJson({
|
||||||
JSON.stringify({
|
|
||||||
uid: instrument.uid,
|
uid: instrument.uid,
|
||||||
figi: instrument.figi,
|
figi: instrument.figi,
|
||||||
ticker: instrument.ticker,
|
ticker: instrument.ticker,
|
||||||
@@ -371,8 +349,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
brand: instrument.brand
|
brand: instrument.brand
|
||||||
? { logoName: instrument.brand.logoName }
|
? { logoName: instrument.brand.logoName }
|
||||||
: undefined,
|
: undefined,
|
||||||
}),
|
});
|
||||||
) as Prisma.InputJsonObject;
|
|
||||||
await tx.tbankInstrument.upsert({
|
await tx.tbankInstrument.upsert({
|
||||||
where: { provider_uid: identity },
|
where: { provider_uid: identity },
|
||||||
update: {},
|
update: {},
|
||||||
@@ -465,6 +442,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
|
|
||||||
finish(attempt: TbankSyncAttempt, success: boolean) {
|
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
|
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||||
await tx.tbankSyncWork.updateMany({
|
await tx.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
...attempt,
|
...attempt,
|
||||||
@@ -473,16 +451,9 @@ export class CatalogDiscoveryRepository {
|
|||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
await tx.tbankSyncWork.updateMany({
|
await claimSyncWork(tx, attempt, {
|
||||||
where: {
|
|
||||||
...attempt,
|
|
||||||
state: 'running',
|
|
||||||
connection: { invalidToken: false },
|
|
||||||
},
|
|
||||||
data: {
|
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { formatDecimal } from '#src/common/decimal';
|
||||||
|
import { decimal } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import type { SavedCandle } from '../domain/candle-coverage';
|
import type { SavedCandle } from '../domain/candle-coverage';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../common/decimal';
|
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
|
||||||
import { timestamp } from '../application/instrument-market';
|
import { timestamp } from '../application/instrument-market';
|
||||||
|
|
||||||
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
import { MoexModule } from '#src/integrations/moex/moex.module';
|
||||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
import { NcrModule } from '#src/integrations/ncr/ncr.module';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||||
|
|
||||||
import { CandleService } from './application/candle.service';
|
import { CandleService } from './application/candle.service';
|
||||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||||
|
import { CatalogSourceLoader } from './application/catalog-source.loader';
|
||||||
import { CandleRepository } from './infrastructure/candle.repository';
|
import { CandleRepository } from './infrastructure/candle.repository';
|
||||||
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||||
|
|
||||||
@@ -15,6 +17,7 @@ import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.r
|
|||||||
CandleService,
|
CandleService,
|
||||||
CandleRepository,
|
CandleRepository,
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
|
CatalogSourceLoader,
|
||||||
CatalogDiscoveryService,
|
CatalogDiscoveryService,
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service';
|
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
|
||||||
import { AuthModule } from '../../auth/auth.module';
|
import { AuthModule } from '../../auth/auth.module';
|
||||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||||
@@ -21,7 +21,6 @@ import { StockController } from './stocks/presentation/stock.controller';
|
|||||||
controllers: [StockController, BondController],
|
controllers: [StockController, BondController],
|
||||||
exports: [StockIncomeService, BondIncomeService],
|
exports: [StockIncomeService, BondIncomeService],
|
||||||
providers: [
|
providers: [
|
||||||
{ provide: 'TBANK_ACCESS', useExisting: TbankAccessService },
|
|
||||||
BondIncomeService,
|
BondIncomeService,
|
||||||
BondMarketService,
|
BondMarketService,
|
||||||
GetStockCompanyUseCase,
|
GetStockCompanyUseCase,
|
||||||
|
|||||||
+4
-5
@@ -1,6 +1,6 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
import {
|
import {
|
||||||
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
|
|||||||
async execute(
|
async execute(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockCompanyResourceDto> {
|
): Promise<StockCompanyResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
uid,
|
uid,
|
||||||
'company',
|
'company',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -72,16 +70,17 @@ export function metrics(
|
|||||||
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
// eslint-disable-next-line security/detect-object-injection -- Keys come from the fixed metric selection declared above.
|
||||||
const value = values[key];
|
const value = values[key];
|
||||||
const currency = values.currency?.toUpperCase() || null;
|
const currency = values.currency?.toUpperCase() || null;
|
||||||
|
const rub = currency === 'RUB';
|
||||||
return typeof value === 'number' &&
|
return typeof value === 'number' &&
|
||||||
Number.isFinite(value) &&
|
Number.isFinite(value) &&
|
||||||
(unit !== 'money' || currency)
|
(unit !== 'money' || rub)
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
key,
|
key,
|
||||||
value,
|
value,
|
||||||
unit,
|
unit,
|
||||||
period,
|
period,
|
||||||
currency: unit === 'money' ? currency : null,
|
currency: unit === 'money' ? 'RUB' : null,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { formatDecimal } from '#src/common/decimal';
|
||||||
|
import { decimal } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import { formatDecimal } from '../../../../../common/decimal';
|
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
|
||||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||||
import { timestamp } from '../../application/instrument-market';
|
import { timestamp } from '../../application/instrument-market';
|
||||||
import {
|
import {
|
||||||
@@ -22,12 +23,10 @@ export class StockCatalogService {
|
|||||||
async search(
|
async search(
|
||||||
userId: string,
|
userId: string,
|
||||||
query: string,
|
query: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockSearchResourceDto> {
|
): Promise<StockSearchResourceDto> {
|
||||||
const result = await this.discovery.search(
|
const result = await this.discovery.search(
|
||||||
userId,
|
userId,
|
||||||
{ instrumentType: 'share', query },
|
{ instrumentType: 'share', query },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -46,7 +45,7 @@ export class StockCatalogService {
|
|||||||
price:
|
price:
|
||||||
last?.price && detail.currency
|
last?.price && detail.currency
|
||||||
? {
|
? {
|
||||||
currency: detail.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(decimal(last.price)),
|
value: formatDecimal(decimal(last.price)),
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
@@ -59,9 +58,8 @@ export class StockCatalogService {
|
|||||||
async get(
|
async get(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockResourceDto> {
|
): Promise<StockResourceDto> {
|
||||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
const result = await this.discovery.detail(userId, 'share', uid);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
data: result.data
|
data: result.data
|
||||||
@@ -73,13 +71,11 @@ export class StockCatalogService {
|
|||||||
async getByTicker(
|
async getByTicker(
|
||||||
userId: string,
|
userId: string,
|
||||||
ticker: string,
|
ticker: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockResourceDto> {
|
): Promise<StockResourceDto> {
|
||||||
const result = await this.discovery.byTicker(
|
const result = await this.discovery.byTicker(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
ticker,
|
ticker,
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
|
|||||||
@@ -4,18 +4,19 @@ import {
|
|||||||
decimalFromString,
|
decimalFromString,
|
||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankDividend,
|
type TbankDividend,
|
||||||
tbankSourceNeedsRefresh,
|
tbankSourceNeedsRefresh,
|
||||||
} from '../../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
import {
|
import {
|
||||||
ForecastService,
|
ForecastService,
|
||||||
PortfolioService,
|
PortfolioService,
|
||||||
} from '../../../portfolio/portfolio.api';
|
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||||
|
|
||||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||||
import { instrumentPosition } from '../../application/instrument-position';
|
import { instrumentPosition } from '../../application/instrument-position';
|
||||||
@@ -51,7 +52,6 @@ export class StockIncomeService {
|
|||||||
async dividends(
|
async dividends(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockDividendsResourceDto> {
|
): Promise<StockDividendsResourceDto> {
|
||||||
const connection = await this.access.local(userId);
|
const connection = await this.access.local(userId);
|
||||||
const today = moscowDay(new Date().toISOString());
|
const today = moscowDay(new Date().toISOString());
|
||||||
@@ -62,7 +62,7 @@ export class StockIncomeService {
|
|||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
[uid],
|
[uid],
|
||||||
observe,
|
false,
|
||||||
['detail', 'dividends'],
|
['detail', 'dividends'],
|
||||||
{ from, to },
|
{ from, to },
|
||||||
);
|
);
|
||||||
@@ -131,11 +131,7 @@ export function stockDividends(
|
|||||||
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const recordDate = timestamp(event.recordDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const paymentDate = timestamp(event.paymentDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
const lastBuyDate = timestamp(event.lastBuyDate)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null;
|
||||||
const currency = event.dividendNet?.currency?.toUpperCase();
|
const perShare = rubMoney(event.dividendNet);
|
||||||
const perShare =
|
|
||||||
currency && event.dividendNet
|
|
||||||
? { currency, value: formatDecimal(decimal(event.dividendNet)) }
|
|
||||||
: null;
|
|
||||||
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
// A future payment can have an already-fixed register: keep the event, but do not estimate entitlement from today's holding.
|
||||||
const eventDate = paymentDate ?? recordDate;
|
const eventDate = paymentDate ?? recordDate;
|
||||||
const timing: StockDividendDto['timing'] = eventDate
|
const timing: StockDividendDto['timing'] = eventDate
|
||||||
@@ -150,7 +146,7 @@ export function stockDividends(
|
|||||||
recordDate >= today &&
|
recordDate >= today &&
|
||||||
timing === 'upcoming'
|
timing === 'upcoming'
|
||||||
? {
|
? {
|
||||||
currency: perShare.currency,
|
currency: 'RUB',
|
||||||
value: formatDecimal(
|
value: formatDecimal(
|
||||||
multiply(quantity, decimalFromString(perShare.value)),
|
multiply(quantity, decimalFromString(perShare.value)),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
multiply,
|
multiply,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../../../common/decimal';
|
} from '#src/common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
import { rubMoney } from '#src/integrations/tbank/decimal';
|
||||||
|
|
||||||
import { CandleService } from '../../application/candle.service';
|
import { CandleService } from '../../application/candle.service';
|
||||||
import {
|
import {
|
||||||
CatalogEnrichmentService,
|
CatalogEnrichmentService,
|
||||||
@@ -34,14 +35,12 @@ export class StockMarketService {
|
|||||||
async quote(
|
async quote(
|
||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
observe = false,
|
|
||||||
): Promise<StockQuoteResourceDto> {
|
): Promise<StockQuoteResourceDto> {
|
||||||
const result = await this.sources.section(
|
const result = await this.sources.section(
|
||||||
userId,
|
userId,
|
||||||
'share',
|
'share',
|
||||||
uid,
|
uid,
|
||||||
'quote',
|
'quote',
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
data: result.values.instrument
|
data: result.values.instrument
|
||||||
@@ -55,12 +54,10 @@ export class StockMarketService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
uid: string,
|
uid: string,
|
||||||
period: StockCandlesQueryDto['period'] = 'month',
|
period: StockCandlesQueryDto['period'] = 'month',
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
return this.history.read(
|
return this.history.read(
|
||||||
userId,
|
userId,
|
||||||
{ uid, instrumentType: 'share', period },
|
{ uid, instrumentType: 'share', period },
|
||||||
observe,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,15 +70,8 @@ export function stockQuote(
|
|||||||
const { price: last, candle: previous, tradingStatus = null } = values;
|
const { price: last, candle: previous, tradingStatus = null } = values;
|
||||||
const share = values.instrument;
|
const share = values.instrument;
|
||||||
const priceAt = timestamp(last?.time);
|
const priceAt = timestamp(last?.time);
|
||||||
const currency = share?.currency?.toUpperCase();
|
const price = last?.price ? rubMoney({ ...last.price, currency: share?.currency }) : null;
|
||||||
const price =
|
const previousPrice = previous?.close ? rubMoney({ ...previous.close, currency: share?.currency }) : null;
|
||||||
last?.price && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(last.price)) }
|
|
||||||
: null;
|
|
||||||
const previousPrice =
|
|
||||||
previous?.close && currency
|
|
||||||
? { currency, value: formatDecimal(decimal(previous.close)) }
|
|
||||||
: null;
|
|
||||||
const difference =
|
const difference =
|
||||||
price && previousPrice
|
price && previousPrice
|
||||||
? subtract(
|
? subtract(
|
||||||
@@ -96,8 +86,8 @@ export function stockQuote(
|
|||||||
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
previousCloseDate: timestamp(previous?.time)?.slice(ISO_DATE_START, ISO_DATE_LENGTH) ?? null,
|
||||||
previousClose: previousPrice,
|
previousClose: previousPrice,
|
||||||
change:
|
change:
|
||||||
difference && currency
|
difference
|
||||||
? { currency, value: formatDecimal(difference) }
|
? { currency: 'RUB', value: formatDecimal(difference) }
|
||||||
: null,
|
: null,
|
||||||
changePercent:
|
changePercent:
|
||||||
difference &&
|
difference &&
|
||||||
|
|||||||
+7
-4
@@ -1,6 +1,10 @@
|
|||||||
|
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||||
|
TbankAccessService: class {},
|
||||||
|
}));
|
||||||
|
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { StockScreenerService } from './stock-screener.service';
|
import { StockScreenerService } from './stock-screener.service';
|
||||||
|
|
||||||
@@ -36,7 +40,7 @@ describe('StockScreenerService', () => {
|
|||||||
client.getAllShares.mockResolvedValue(
|
client.getAllShares.mockResolvedValue(
|
||||||
Array.from({ length: pageSize + 1 }, (_, index) => share(index)),
|
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 () => {
|
it('stores the full selected catalog and returns stable non-overlapping pages', async () => {
|
||||||
@@ -83,7 +87,6 @@ describe('StockScreenerService', () => {
|
|||||||
client.getAllShares.mockResolvedValueOnce([
|
client.getAllShares.mockResolvedValueOnce([
|
||||||
share(1, {
|
share(1, {
|
||||||
countryOfRisk: undefined,
|
countryOfRisk: undefined,
|
||||||
currency: undefined,
|
|
||||||
isin: undefined,
|
isin: undefined,
|
||||||
realExchange: undefined,
|
realExchange: undefined,
|
||||||
sector: undefined,
|
sector: undefined,
|
||||||
@@ -92,7 +95,7 @@ describe('StockScreenerService', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
const result = await service.search('user-1', {});
|
const result = await service.search('user-1', {});
|
||||||
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: null, isin: null, logoName: null, ticker: null }));
|
expect(result.items[0]).toEqual(expect.objectContaining({ countryOfRisk: null, currency: 'RUB', isin: null, logoName: null, ticker: null }));
|
||||||
|
|
||||||
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
client.getAllShares.mockResolvedValueOnce([share(2, { name: ' ' })]);
|
||||||
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
await expect(service.search('user-1', {})).rejects.toBeInstanceOf(ServiceUnavailableException);
|
||||||
|
|||||||
@@ -1,16 +1,17 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
Inject,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
ServiceUnavailableException,
|
ServiceUnavailableException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types';
|
import type { TbankShare } from '#src/integrations/tbank/tbank-market.types';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
|
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
|
||||||
|
import { TbankClient } from '#src/integrations/tbank/tbank.client';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
|
|
||||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
|
||||||
import {
|
import {
|
||||||
selectStockScreenerInstruments,
|
selectStockScreenerInstruments,
|
||||||
type StockScreenerConditions,
|
type StockScreenerConditions,
|
||||||
@@ -47,17 +48,13 @@ type StockScreenerPage = {
|
|||||||
searchId: string;
|
searchId: string;
|
||||||
};
|
};
|
||||||
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string };
|
||||||
type TbankAccess = {
|
|
||||||
local(userId: string): Promise<{ revision: string }>;
|
|
||||||
online<T>(userId: string, run: (connection: { revision: string; token: string }) => Promise<T>): Promise<T>;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StockScreenerService {
|
export class StockScreenerService {
|
||||||
private readonly snapshots = new Map<string, Snapshot>();
|
private readonly snapshots = new Map<string, Snapshot>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@Inject('TBANK_ACCESS') private readonly access: TbankAccess,
|
private readonly access: TbankAccessService,
|
||||||
private readonly client: TbankClient,
|
private readonly client: TbankClient,
|
||||||
private readonly logger: StructuredLogger,
|
private readonly logger: StructuredLogger,
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { previousClose } from '../../application/instrument-market';
|
|||||||
import { metrics } from './get-stock-company.use-case';
|
import { metrics } from './get-stock-company.use-case';
|
||||||
import { stockDividends } from './stock-income.service';
|
import { stockDividends } from './stock-income.service';
|
||||||
import { stockQuote } from './stock-market.service';
|
import { stockQuote } from './stock-market.service';
|
||||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
jest.mock('#src/prisma/prisma.service', () => ({
|
||||||
PrismaService: class {},
|
PrismaService: class {},
|
||||||
}));
|
}));
|
||||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||||
|
|||||||
+10
-3
@@ -1,4 +1,4 @@
|
|||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
|
||||||
|
|
||||||
@@ -29,16 +29,23 @@ describe('selectStockScreenerInstruments', () => {
|
|||||||
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
share({ isin: 'D', sector: 'finance', uid: 'uid-4' }),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ currency: 'RUB', query: 'star', sector: 'energy' },
|
{ query: 'star', sector: 'energy' },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
expect(result).toEqual({ asOf, instruments: [share({ isin: 'A', sector: 'energy' })] });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('filters out non-RUB instruments', () => {
|
||||||
|
const result = selectStockScreenerInstruments(
|
||||||
|
{ asOf, instruments: [share({ currency: 'USD' }), share({ currency: undefined })] },
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
expect(result.instruments).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it.each([
|
it.each([
|
||||||
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
['sector', { sector: 'energy' }, share({ sector: undefined })],
|
||||||
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
['share type', { shareType: 'common' }, share({ shareType: 'preferred' })],
|
||||||
['currency', { currency: 'USD' }, share({ currency: undefined })],
|
|
||||||
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
['exchange', { realExchange: 'MOEX' }, share({ realExchange: undefined })],
|
||||||
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
['country of risk', { countryOfRisk: 'RU' }, share({ countryOfRisk: 'US' })],
|
||||||
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
['query', { query: 'star' }, share({ name: undefined, ticker: undefined })],
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
export type StockScreenerCatalog = {
|
export type StockScreenerCatalog = {
|
||||||
asOf: Date;
|
asOf: Date;
|
||||||
@@ -7,7 +7,6 @@ export type StockScreenerCatalog = {
|
|||||||
|
|
||||||
export type StockScreenerConditions = {
|
export type StockScreenerConditions = {
|
||||||
countryOfRisk?: string;
|
countryOfRisk?: string;
|
||||||
currency?: string;
|
|
||||||
maxLot?: number;
|
maxLot?: number;
|
||||||
query?: string;
|
query?: string;
|
||||||
realExchange?: string;
|
realExchange?: string;
|
||||||
@@ -76,12 +75,7 @@ function matches(
|
|||||||
) {
|
) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (
|
if (instrument.currency?.toUpperCase() !== 'RUB') return false;
|
||||||
conditions.currency !== undefined &&
|
|
||||||
instrument.currency !== conditions.currency
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
conditions.realExchange !== undefined &&
|
conditions.realExchange !== undefined &&
|
||||||
instrument.realExchange !== conditions.realExchange
|
instrument.realExchange !== conditions.realExchange
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ import {
|
|||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
|
||||||
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
||||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||||
import { StockIncomeService } from '../application/stock-income.service';
|
import { StockIncomeService } from '../application/stock-income.service';
|
||||||
@@ -64,7 +64,6 @@ export class StockController {
|
|||||||
return this.stocks.search(
|
return this.stocks.search(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
query.query,
|
query.query,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,12 +72,10 @@ export class StockController {
|
|||||||
getByTicker(
|
getByTicker(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param() params: StockTickerParamsDto,
|
@Param() params: StockTickerParamsDto,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.stocks.getByTicker(
|
return this.stocks.getByTicker(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
params.ticker,
|
params.ticker,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,7 +88,8 @@ export class StockController {
|
|||||||
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
@Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true }))
|
||||||
criteria: StockScreenerCriteriaDto,
|
criteria: StockScreenerCriteriaDto,
|
||||||
) {
|
) {
|
||||||
const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
// 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', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']);
|
||||||
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
if (Object.keys(request.body as object).some((key) => !allowed.has(key))) {
|
||||||
throw new BadRequestException();
|
throw new BadRequestException();
|
||||||
}
|
}
|
||||||
@@ -114,9 +112,8 @@ export class StockController {
|
|||||||
get(
|
get(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.stocks.get(request.user.sub, uid, query.sync === 'observe');
|
return this.stocks.get(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/quote')
|
@Get(':uid/quote')
|
||||||
@@ -124,9 +121,8 @@ export class StockController {
|
|||||||
quote(
|
quote(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
return this.market.quote(request.user.sub, uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get(':uid/candles')
|
@Get(':uid/candles')
|
||||||
@@ -140,7 +136,6 @@ export class StockController {
|
|||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.period,
|
query.period,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +153,10 @@ export class StockController {
|
|||||||
company(
|
company(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.companyInfo.execute(
|
return this.companyInfo.execute(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,12 +165,10 @@ export class StockController {
|
|||||||
dividends(
|
dividends(
|
||||||
@Req() request: Request & { user: AccessPayload },
|
@Req() request: Request & { user: AccessPayload },
|
||||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||||
@Query() query: InvestmentReadQueryDto,
|
|
||||||
) {
|
) {
|
||||||
return this.income.dividends(
|
return this.income.dividends(
|
||||||
request.user.sub,
|
request.user.sub,
|
||||||
uid,
|
uid,
|
||||||
query.sync === 'observe',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,16 +11,15 @@ import {
|
|||||||
MinLength,
|
MinLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
import { MoneyDto } from '../../../../../common/money.dto';
|
import { MoneyDto } from '#src/common/money.dto';
|
||||||
import {
|
import {
|
||||||
MAX_INSTRUMENT_QUERY_LENGTH,
|
MAX_INSTRUMENT_QUERY_LENGTH,
|
||||||
MIN_INSTRUMENT_QUERY_LENGTH,
|
MIN_INSTRUMENT_QUERY_LENGTH,
|
||||||
} from '../../../instrument-query';
|
} from '#src/modules/investments/instrument-query';
|
||||||
import {
|
import {
|
||||||
InvestmentReadQueryDto,
|
|
||||||
InvestmentResourceMetaDto,
|
InvestmentResourceMetaDto,
|
||||||
} from '../../../investment-resource.dto';
|
} from '#src/modules/investments/investment-resource.dto';
|
||||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||||
|
|
||||||
const MIN_TICKER_LENGTH = 1;
|
const MIN_TICKER_LENGTH = 1;
|
||||||
const MAX_SCREENER_QUERY_LENGTH = 80;
|
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||||
@@ -53,7 +52,6 @@ export class StockScreenerCriteriaDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(MIN_TICKER_LENGTH)
|
@MinLength(MIN_TICKER_LENGTH)
|
||||||
currency?: string;
|
|
||||||
|
|
||||||
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
@ApiPropertyOptional({ minLength: MIN_TICKER_LENGTH })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -104,7 +102,7 @@ export class StockScreenerPageDto {
|
|||||||
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export class StockSearchQueryDto extends InvestmentReadQueryDto {
|
export class StockSearchQueryDto {
|
||||||
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
|
||||||
@Transform(({ value }: { value: unknown }) =>
|
@Transform(({ value }: { value: unknown }) =>
|
||||||
typeof value === 'string' ? value.trim() : value,
|
typeof value === 'string' ? value.trim() : value,
|
||||||
@@ -126,7 +124,7 @@ export class StockTickerParamsDto {
|
|||||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||||
declare ticker: string;
|
declare ticker: string;
|
||||||
}
|
}
|
||||||
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
export class StockCandlesQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: ['month', 'halfYear', 'year', 'all'],
|
enum: ['month', 'halfYear', 'year', 'all'],
|
||||||
default: 'month',
|
default: 'month',
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { IsIn, IsOptional } from 'class-validator';
|
|
||||||
|
|
||||||
export const availabilityValues = [
|
export const availabilityValues = [
|
||||||
'available',
|
'available',
|
||||||
@@ -17,13 +16,6 @@ export const attemptStateValues = [
|
|||||||
'failed',
|
'failed',
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export class InvestmentReadQueryDto {
|
|
||||||
@ApiPropertyOptional({ enum: ['observe'] })
|
|
||||||
@IsOptional()
|
|
||||||
@IsIn(['observe'])
|
|
||||||
declare sync?: 'observe';
|
|
||||||
}
|
|
||||||
|
|
||||||
export class InvestmentAttemptProblemDto {
|
export class InvestmentAttemptProblemDto {
|
||||||
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||||
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||||
|
|||||||
@@ -1,196 +1,16 @@
|
|||||||
import {
|
import { accountHistory, ledgerRange } from './investment-sync-plan';
|
||||||
accountHistory,
|
|
||||||
ledgerRange,
|
|
||||||
planInvestmentScreen,
|
|
||||||
} 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 now = new Date('2026-09-25T12:00:00Z');
|
||||||
const context = {
|
const accounts = [{ accountId: 'a', openedOn: null }];
|
||||||
membershipKnown: true,
|
|
||||||
accounts: [{ accountId: 'a', openedOn: '2020-01-01' }],
|
it('retains the unknown opening boundary for history and instrument ledger', () => {
|
||||||
instruments: [
|
expect(accountHistory(accounts, now)[0].from.toISOString()).toBe(
|
||||||
{
|
|
||||||
uid: 'uid',
|
|
||||||
type: 'STOCK' as const,
|
|
||||||
historyFrom: new Date('2020-01-01Z'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
it('prioritizes discovery, the selected period, 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',
|
|
||||||
);
|
|
||||||
// The single selected range is chunked into month-aligned intervals.
|
|
||||||
expect(selected).toHaveLength(2);
|
|
||||||
expect(
|
|
||||||
selected.every(
|
|
||||||
(source) =>
|
|
||||||
source.from != null &&
|
|
||||||
source.from.toISOString() >= '2024-01-31T21:00:00.000Z' &&
|
|
||||||
source.to != null &&
|
|
||||||
source.to.toISOString() <= '2024-02-29T21:00:00.000Z',
|
|
||||||
),
|
|
||||||
).toBe(true);
|
|
||||||
expect(
|
|
||||||
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([]);
|
|
||||||
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(
|
|
||||||
'1970-01-01T00:00:00.000Z',
|
'1970-01-01T00:00:00.000Z',
|
||||||
);
|
);
|
||||||
expect(
|
expect(
|
||||||
ledgerRange(
|
ledgerRange(undefined, undefined, 'uid', accounts, now).from.toISOString(),
|
||||||
undefined,
|
|
||||||
undefined,
|
|
||||||
'uid',
|
|
||||||
unknown.accounts,
|
|
||||||
now,
|
|
||||||
).from.toISOString(),
|
|
||||||
).toBe('1970-01-01T00:00:00.000Z');
|
).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,267 +1,11 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import type { OperationInterval } from './operations/domain/operations-sync-plan';
|
||||||
import { planIncomePeriod } from './income/income.api';
|
|
||||||
import { dateOnlyBounds, moscowDate } from './investment-calendar';
|
|
||||||
import {
|
|
||||||
type OperationInterval,
|
|
||||||
operationIntervals,
|
|
||||||
} from './operations/domain/operations-sync-plan';
|
|
||||||
|
|
||||||
const FORECAST_YEARS = 1;
|
|
||||||
const JANUARY_INDEX = 0;
|
const JANUARY_INDEX = 0;
|
||||||
const FIRST_DAY_OF_MONTH = 1;
|
const FIRST_DAY_OF_MONTH = 1;
|
||||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
const EXCLUSIVE_END_OFFSET_MS = 1;
|
||||||
const NO_END_OFFSET_MS = 0;
|
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 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,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
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,
|
|
||||||
);
|
|
||||||
const bounds = dateOnlyBounds(period.selected, now);
|
|
||||||
operations(bounds.from, bounds.to);
|
|
||||||
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(
|
export function accountHistory(
|
||||||
accounts: Account[],
|
accounts: Account[],
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { MoexModule } from '#src/integrations/moex/moex.module';
|
||||||
|
|
||||||
|
import { MarketIndexRepository } from './market-index.repository';
|
||||||
|
import { MarketIndexHistoryService } from './market-index.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [MoexModule],
|
||||||
|
providers: [MarketIndexHistoryService, MarketIndexRepository],
|
||||||
|
exports: [MarketIndexHistoryService],
|
||||||
|
})
|
||||||
|
export class MarketIndexModule {}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import type { MoexIndexClose } from '#src/integrations/moex/moex.client';
|
||||||
|
|
||||||
|
import { PrismaService } from '#src/prisma/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketIndexRepository {
|
||||||
|
constructor(private readonly prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async read(secid: 'IMOEX' | 'MCFTRR', from: string, to: string) {
|
||||||
|
const [closes, state] = await Promise.all([
|
||||||
|
this.prisma.marketIndexDailyClose.findMany({
|
||||||
|
where: { secid, tradeDate: { gte: from, lte: to } },
|
||||||
|
orderBy: { tradeDate: 'asc' },
|
||||||
|
}),
|
||||||
|
this.prisma.marketIndexSyncState.findUnique({ where: { secid } }),
|
||||||
|
]);
|
||||||
|
return { closes, state };
|
||||||
|
}
|
||||||
|
|
||||||
|
async attempt(secid: 'IMOEX' | 'MCFTRR', at: Date) {
|
||||||
|
await this.prisma.marketIndexSyncState.upsert({
|
||||||
|
where: { secid }, create: { secid, lastAttemptAt: at }, update: { lastAttemptAt: at },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// coveredTo is the `till` MOEX answered for: a close for today that is not
|
||||||
|
// published yet is re-checked by the service while the last close < to.
|
||||||
|
async save(secid: 'IMOEX' | 'MCFTRR', closes: MoexIndexClose[], from: string, to: string, at: Date) {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
for (const close of closes) {
|
||||||
|
await tx.marketIndexDailyClose.upsert({
|
||||||
|
where: { secid_tradeDate: { secid, tradeDate: close.tradeDate } },
|
||||||
|
create: { secid, ...close },
|
||||||
|
update: { close: close.close },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const state = await tx.marketIndexSyncState.findUniqueOrThrow({ where: { secid } });
|
||||||
|
const joined = Boolean(state.coveredFrom && state.coveredTo &&
|
||||||
|
from <= state.coveredTo && to >= state.coveredFrom);
|
||||||
|
await tx.marketIndexSyncState.update({
|
||||||
|
where: { secid },
|
||||||
|
data: {
|
||||||
|
lastSuccessAt: at,
|
||||||
|
coveredFrom: joined && state.coveredFrom && state.coveredFrom < from ? state.coveredFrom : from,
|
||||||
|
coveredTo: joined && state.coveredTo && state.coveredTo > to ? state.coveredTo : to,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
import { MarketIndexHistoryService } from './market-index.service';
|
||||||
|
|
||||||
|
jest.mock('./market-index.repository', () => ({ MarketIndexRepository: class {} }));
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
coveredFrom: null | string;
|
||||||
|
coveredTo: null | string;
|
||||||
|
lastAttemptAt: Date | null;
|
||||||
|
lastSuccessAt: Date | null;
|
||||||
|
};
|
||||||
|
type Close = { close: string; tradeDate: string };
|
||||||
|
|
||||||
|
const HOUR_MS = 3_600_000;
|
||||||
|
|
||||||
|
// In-memory stand-in mirroring the repository's merge rules.
|
||||||
|
function setup(history: Close[]) {
|
||||||
|
let state: null | State = null;
|
||||||
|
const stored = new Map<string, string>();
|
||||||
|
const repository = {
|
||||||
|
read: jest.fn((_: string, from: string, to: string) => Promise.resolve({
|
||||||
|
closes: [...stored.entries()]
|
||||||
|
.filter(([date]) => date >= from && date <= to)
|
||||||
|
.sort(([a], [b]) => a.localeCompare(b))
|
||||||
|
.map(([tradeDate, close]) => ({ tradeDate, close })),
|
||||||
|
state: state && { ...state },
|
||||||
|
})),
|
||||||
|
attempt: jest.fn((_: string, at: Date) => {
|
||||||
|
state = { coveredFrom: null, coveredTo: null, lastSuccessAt: null, ...state, lastAttemptAt: at };
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
save: jest.fn((_: string, closes: Close[], from: string, to: string, at: Date) => {
|
||||||
|
for (const close of closes) stored.set(close.tradeDate, close.close);
|
||||||
|
const current = state as State;
|
||||||
|
const joined = Boolean(current.coveredFrom && current.coveredTo &&
|
||||||
|
from <= current.coveredTo && to >= current.coveredFrom);
|
||||||
|
state = {
|
||||||
|
...current,
|
||||||
|
lastSuccessAt: at,
|
||||||
|
coveredFrom: joined && current.coveredFrom && current.coveredFrom < from ? current.coveredFrom : from,
|
||||||
|
coveredTo: joined && current.coveredTo && current.coveredTo > to ? current.coveredTo : to,
|
||||||
|
};
|
||||||
|
return Promise.resolve();
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
const moex = {
|
||||||
|
indexHistory: jest.fn((_: string, from: string, till: string) => Promise.resolve(
|
||||||
|
history.filter((row) => row.tradeDate >= from && row.tradeDate <= till),
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
const service = new MarketIndexHistoryService(
|
||||||
|
repository as never, moex as never, { event: jest.fn() } as never,
|
||||||
|
);
|
||||||
|
return { service, moex, setHistory: (rows: Close[]) => history.splice(0, history.length, ...rows) };
|
||||||
|
}
|
||||||
|
|
||||||
|
const closes = (...dates: string[]) => dates.map((tradeDate) => ({ tradeDate, close: '100' }));
|
||||||
|
|
||||||
|
describe('MarketIndexHistoryService', () => {
|
||||||
|
afterEach(() => jest.useRealTimers());
|
||||||
|
|
||||||
|
it('loads a ten-day lookback once and reuses the confirmed range', async () => {
|
||||||
|
const { service, moex } = setup(closes('2026-09-30', '2026-10-02', '2026-10-05'));
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(3);
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledWith('MCFTRR', '2026-09-22', '2026-10-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('extends history backward at once, even right after a successful load', async () => {
|
||||||
|
const { service, moex } = setup(closes('2023-01-03', '2026-09-30', '2026-10-05'));
|
||||||
|
await service.read('MCFTRR', '2026-10-01', '2026-10-05');
|
||||||
|
expect(await service.read('MCFTRR', '2023-01-05', '2026-10-05')).toHaveLength(3);
|
||||||
|
expect(moex.indexHistory).toHaveBeenLastCalledWith('MCFTRR', '2022-12-26', '2026-10-05');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('re-checks a missing tail close after an hour, but not sooner', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
|
||||||
|
const { service, moex, setHistory } = setup(closes('2026-09-30', '2026-10-02'));
|
||||||
|
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||||
|
setHistory(closes('2026-09-30', '2026-10-02', '2026-10-05'));
|
||||||
|
await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||||
|
jest.setSystemTime(Date.now() + HOUR_MS + 1);
|
||||||
|
const rows = await service.read('MCFTRR', '2026-10-02', '2026-10-05');
|
||||||
|
expect(rows?.at(-1)?.tradeDate).toBe('2026-10-05');
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps saved history usable when a later refresh fails and throttles retries', async () => {
|
||||||
|
jest.useFakeTimers({ now: new Date('2026-10-05T12:00:00Z') });
|
||||||
|
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||||
|
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
|
||||||
|
jest.setSystemTime(Date.now() + 1000);
|
||||||
|
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toHaveLength(2);
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns no history when nothing was ever loaded and MOEX fails', async () => {
|
||||||
|
const { service, moex } = setup([]);
|
||||||
|
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-05')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a range that does not reach the last cash flow', async () => {
|
||||||
|
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||||
|
await service.read('MCFTRR', '2026-10-02', '2026-10-02');
|
||||||
|
moex.indexHistory.mockRejectedValue(new Error('MOEX down'));
|
||||||
|
expect(await service.read('MCFTRR', '2026-10-02', '2026-10-09', '2026-10-08')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shares one request for parallel reads', async () => {
|
||||||
|
const { service, moex } = setup(closes('2026-09-30', '2026-10-02'));
|
||||||
|
await Promise.all([
|
||||||
|
service.read('IMOEX', '2026-10-02', '2026-10-05'),
|
||||||
|
service.read('IMOEX', '2026-10-02', '2026-10-05'),
|
||||||
|
]);
|
||||||
|
expect(moex.indexHistory).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
|
import { MoexClient, type MoexIndexClose } from '#src/integrations/moex/moex.client';
|
||||||
|
|
||||||
|
import { MarketIndexRepository } from './market-index.repository';
|
||||||
|
|
||||||
|
const HOUR_MS = 3_600_000;
|
||||||
|
const LOOKBACK_DAYS = 10;
|
||||||
|
const ISO_DATE_LENGTH = 10;
|
||||||
|
const ISO_DATE_START = 0;
|
||||||
|
const LAST_CLOSE = -1;
|
||||||
|
const MAX_REFRESH_ROUNDS = 2;
|
||||||
|
const FIRST_ROUND = 0;
|
||||||
|
|
||||||
|
type IndexId = 'IMOEX' | 'MCFTRR';
|
||||||
|
type Saved = Awaited<ReturnType<MarketIndexRepository['read']>>;
|
||||||
|
|
||||||
|
function shiftDays(date: string, days: number): string {
|
||||||
|
const value = new Date(`${date}T00:00:00Z`);
|
||||||
|
value.setUTCDate(value.getUTCDate() + days);
|
||||||
|
return value.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
function within(at: Date | null | undefined, ms: number) {
|
||||||
|
return Boolean(at && Date.now() - at.valueOf() < ms);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MarketIndexHistoryService {
|
||||||
|
private readonly pending = new Map<IndexId, Promise<void>>();
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly repository: MarketIndexRepository,
|
||||||
|
private readonly moex: MoexClient,
|
||||||
|
private readonly logger: StructuredLogger,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes covering [from − 10 days, to]. `requiredThrough` is the latest date
|
||||||
|
* that must have a close on or after it (the last cash flow); null when the
|
||||||
|
* confirmed history does not cover the period.
|
||||||
|
*/
|
||||||
|
async read(
|
||||||
|
secid: IndexId,
|
||||||
|
from: string,
|
||||||
|
to: string,
|
||||||
|
requiredThrough = from,
|
||||||
|
): Promise<MoexIndexClose[] | null> {
|
||||||
|
const rangeFrom = shiftDays(from, -LOOKBACK_DAYS);
|
||||||
|
let saved = await this.repository.read(secid, rangeFrom, to);
|
||||||
|
for (let round = FIRST_ROUND; round < MAX_REFRESH_ROUNDS; round++) {
|
||||||
|
const range = this.refreshRange(saved, rangeFrom, to);
|
||||||
|
if (!range) break;
|
||||||
|
const job = this.pending.get(secid) ?? this.start(secid, range.from, to);
|
||||||
|
await job;
|
||||||
|
saved = await this.repository.read(secid, rangeFrom, to);
|
||||||
|
}
|
||||||
|
return this.covered(saved, from, rangeFrom, to, requiredThrough)
|
||||||
|
? saved.closes.map(({ tradeDate, close }) => ({ tradeDate, close }))
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// coveredFrom..coveredTo is the range MOEX was successfully asked about.
|
||||||
|
// Extending it backward is fetched at once; an unpublished tail close is
|
||||||
|
// re-checked at most hourly; a failed attempt is retried at most hourly.
|
||||||
|
private refreshRange(saved: Saved, rangeFrom: string, to: string) {
|
||||||
|
const state = saved.state;
|
||||||
|
const lastAttempt = state?.lastAttemptAt ?? null;
|
||||||
|
const lastSuccess = state?.lastSuccessAt ?? null;
|
||||||
|
// Success is stamped with its own attempt time, so equal means success.
|
||||||
|
const failed = lastAttempt !== null && (lastSuccess === null || lastSuccess.valueOf() !== lastAttempt.valueOf());
|
||||||
|
if (failed && within(lastAttempt, HOUR_MS)) return null;
|
||||||
|
if (!state?.coveredFrom || !state.coveredTo || state.coveredFrom > rangeFrom) return { from: rangeFrom };
|
||||||
|
if (state.coveredTo < to) return { from: state.coveredTo };
|
||||||
|
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
|
||||||
|
if ((!last || last < to) && !within(lastSuccess, HOUR_MS)) return { from: last ?? rangeFrom };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private covered(saved: Saved, from: string, rangeFrom: string, to: string, requiredThrough: string) {
|
||||||
|
const last = saved.closes.at(LAST_CLOSE)?.tradeDate;
|
||||||
|
return Boolean(
|
||||||
|
saved.state?.coveredFrom && saved.state.coveredFrom <= rangeFrom &&
|
||||||
|
saved.closes.some((row) => row.tradeDate <= from) &&
|
||||||
|
last && last >= shiftDays(to, -LOOKBACK_DAYS) &&
|
||||||
|
// The last cash flow must lie inside the checked range, otherwise it
|
||||||
|
// would be priced by a stale close.
|
||||||
|
saved.state.coveredTo && saved.state.coveredTo >= requiredThrough,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private start(secid: IndexId, from: string, to: string): Promise<void> {
|
||||||
|
const job = this.refresh(secid, from, to).finally(() => this.pending.delete(secid));
|
||||||
|
this.pending.set(secid, job);
|
||||||
|
return job;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async refresh(secid: IndexId, from: string, to: string): Promise<void> {
|
||||||
|
const at = new Date();
|
||||||
|
const started = Date.now();
|
||||||
|
await this.repository.attempt(secid, at);
|
||||||
|
try {
|
||||||
|
const closes = await this.moex.indexHistory(secid, from, to);
|
||||||
|
await this.repository.save(secid, closes, from, to, at);
|
||||||
|
this.logger.event('info', 'integration.moex.index_history.succeeded', {
|
||||||
|
operation: secid, count: closes.length, durationMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
this.logger.event('warn', 'integration.moex.index_history.failed', {
|
||||||
|
operation: secid, durationMs: Date.now() - started,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export const operationCategories = [
|
||||||
|
'trade',
|
||||||
|
'income',
|
||||||
|
'tax',
|
||||||
|
'fee',
|
||||||
|
'transfer',
|
||||||
|
'other',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type OperationCategory = (typeof operationCategories)[number];
|
||||||
+6
-32
@@ -1,18 +1,17 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||||
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
import { TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
|
||||||
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
import { TbankAccessService, TbankClient } from '#src/integrations/tbank/tbank.api';
|
||||||
import { accountHistory } from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
uncoveredOperationIntervals,
|
|
||||||
} from '../domain/operations-sync-plan';
|
} from '../domain/operations-sync-plan';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsRepository,
|
BrokerageOperationsRepository,
|
||||||
@@ -48,7 +47,6 @@ export class BrokerageOperationsSyncService {
|
|||||||
intervals: OperationInterval[],
|
intervals: OperationInterval[],
|
||||||
options: {
|
options: {
|
||||||
force?: boolean;
|
force?: boolean;
|
||||||
history?: boolean;
|
|
||||||
movingEnd?: boolean;
|
movingEnd?: boolean;
|
||||||
observe?: boolean;
|
observe?: boolean;
|
||||||
revision?: string;
|
revision?: string;
|
||||||
@@ -99,30 +97,6 @@ export class BrokerageOperationsSyncService {
|
|||||||
connection.revision,
|
connection.revision,
|
||||||
)
|
)
|
||||||
: null;
|
: 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 };
|
return { plan, handle };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,68 +1,57 @@
|
|||||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { createHash } from 'node:crypto';
|
||||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
metadataKey,
|
metadataKey,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankMembershipService,
|
|
||||||
type TbankOperationItem,
|
type TbankOperationItem,
|
||||||
tbankSourceTtlMs,
|
tbankSourceTtlMs,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '#src/integrations/tbank/tbank.api';
|
||||||
|
|
||||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
import {
|
import { ledgerRange } from '../../investment-sync-plan';
|
||||||
accountHistory,
|
|
||||||
ledgerRange,
|
|
||||||
planInvestmentScreen,
|
|
||||||
} from '../../investment-sync-plan';
|
|
||||||
import {
|
import {
|
||||||
hourlyAttempt,
|
hourlyAttempt,
|
||||||
TbankSyncRepository,
|
TbankSyncRepository,
|
||||||
} from '../../tbank-sync/tbank-sync.api';
|
} from '../../tbank-sync/tbank-sync.api';
|
||||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
import {
|
||||||
|
coverageAsOf,
|
||||||
|
deriveReadCoverage,
|
||||||
|
historyState,
|
||||||
|
serializeRange,
|
||||||
|
} from '../domain/operations-coverage';
|
||||||
import {
|
import {
|
||||||
confirmedOperationRanges,
|
confirmedOperationRanges,
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
|
||||||
planOperationRead,
|
planOperationRead,
|
||||||
uncoveredOperationIntervals,
|
uncoveredOperationIntervals,
|
||||||
} from '../domain/operations-sync-plan';
|
} from '../domain/operations-sync-plan';
|
||||||
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
||||||
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
import { BrokerageOperationsReadRepository } from '../infrastructure/brokerage-operations-read.repository';
|
||||||
import {
|
import {
|
||||||
BrokerageOperationsQueryDto,
|
BrokerageOperationsQueryDto,
|
||||||
DEFAULT_OPERATIONS_PAGE_SIZE,
|
DEFAULT_OPERATIONS_PAGE_SIZE,
|
||||||
} from '../presentation/brokerage-operation.dto';
|
} from '../presentation/brokerage-operation.dto';
|
||||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||||
|
import { LocalOperationsCursorCodec } from './local-operations-cursor.codec';
|
||||||
|
|
||||||
const EXCLUSIVE_END_OFFSET_MS = 1;
|
|
||||||
const FIRST_RESULT_OFFSET = 0;
|
const FIRST_RESULT_OFFSET = 0;
|
||||||
const MISSING_MEMBERSHIP_REVISION = 0;
|
const MISSING_MEMBERSHIP_REVISION = 0;
|
||||||
const STALE_FRESHNESS_MS = 0;
|
const STALE_FRESHNESS_MS = 0;
|
||||||
const UNKNOWN_AS_OF_MS = 0;
|
const NO_COVERAGE_ROWS = 0;
|
||||||
const LAST_ROW_INDEX = -1;
|
const LAST_ROW_INDEX = -1;
|
||||||
const UNIX_EPOCH_MS = 0;
|
const UNIX_EPOCH_MS = 0;
|
||||||
|
|
||||||
type LocalOperationsCursor = {
|
|
||||||
accountId: string;
|
|
||||||
at: string;
|
|
||||||
binding: string;
|
|
||||||
id: string;
|
|
||||||
movingEnd?: boolean;
|
|
||||||
to: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BrokerageOperationsService {
|
export class BrokerageOperationsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly access: TbankAccessService,
|
private readonly access: TbankAccessService,
|
||||||
private readonly metadata: InstrumentMetadataService,
|
private readonly metadata: InstrumentMetadataService,
|
||||||
private readonly repository: BrokerageOperationsRepository,
|
private readonly repository: BrokerageOperationsReadRepository,
|
||||||
private readonly sync: BrokerageOperationsSyncService,
|
private readonly sync: BrokerageOperationsSyncService,
|
||||||
private readonly membership: TbankMembershipService,
|
private readonly cursorCodec: LocalOperationsCursorCodec,
|
||||||
private readonly config: ConfigService,
|
|
||||||
private readonly hourlySync: TbankSyncRepository,
|
private readonly hourlySync: TbankSyncRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@@ -72,7 +61,7 @@ export class BrokerageOperationsService {
|
|||||||
query: BrokerageOperationsQueryDto,
|
query: BrokerageOperationsQueryDto,
|
||||||
) {
|
) {
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
const cursor = query.cursor ? this.decodeCursor(query.cursor) : undefined;
|
const cursor = query.cursor ? this.cursorCodec.decode(query.cursor) : undefined;
|
||||||
const selected = connection.accounts.filter(
|
const selected = connection.accounts.filter(
|
||||||
(account) => accountId === 'all' || account.accountId === accountId,
|
(account) => accountId === 'all' || account.accountId === accountId,
|
||||||
);
|
);
|
||||||
@@ -117,99 +106,32 @@ export class BrokerageOperationsService {
|
|||||||
cursor,
|
cursor,
|
||||||
};
|
};
|
||||||
if (cursor) await this.repository.readPage(readInput);
|
if (cursor) await this.repository.readPage(readInput);
|
||||||
const screen = planInvestmentScreen(
|
const intervals = selected.flatMap((account) =>
|
||||||
{
|
from < to ? [{ accountId: account.accountId, from, to }] : [],
|
||||||
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 }]
|
|
||||||
: [],
|
|
||||||
);
|
);
|
||||||
// The ledger only starts the first-time load of missing intervals; the
|
// The ledger only starts the first-time load of missing intervals; the
|
||||||
// mutable tail belongs to the hourly walk and membership is not rechecked.
|
// mutable tail belongs to the hourly walk and membership is not rechecked.
|
||||||
if (intervals.length) {
|
if (intervals.length) {
|
||||||
await this.sync.readPlan(userId, intervals, {
|
await this.sync.readPlan(userId, intervals, {
|
||||||
movingEnd,
|
movingEnd,
|
||||||
history: true,
|
|
||||||
revision: connection.revision,
|
revision: connection.revision,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const saved = await this.repository.readPage(readInput);
|
const saved = await this.repository.readPage(readInput);
|
||||||
const hourly = await this.hourlySync.hourly(userId);
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const accounts = saved?.connection.accounts ?? [];
|
const accounts = saved?.connection.accounts ?? [];
|
||||||
const targets = accounts.flatMap((account) =>
|
const view = deriveReadCoverage({
|
||||||
from < to
|
accounts,
|
||||||
? [
|
connectionMembershipAt: saved?.connection.membershipAt ?? null,
|
||||||
{
|
coverage: saved?.coverage ?? [],
|
||||||
accountId: account.accountId,
|
|
||||||
from,
|
from,
|
||||||
to,
|
|
||||||
instrumentUid: query.instrumentUid,
|
instrumentUid: query.instrumentUid,
|
||||||
},
|
|
||||||
]
|
|
||||||
: [],
|
|
||||||
);
|
|
||||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
|
||||||
movingEnd,
|
movingEnd,
|
||||||
});
|
|
||||||
const confirmed = confirmedOperationRanges(plan.intervals, saved?.coverage ?? []);
|
|
||||||
const missing = uncoveredOperationIntervals(
|
|
||||||
plan.intervals,
|
|
||||||
confirmed.map((range) => ({
|
|
||||||
...range,
|
|
||||||
instrumentUid: query.instrumentUid,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
const available = Boolean(saved?.connection.membershipAt);
|
|
||||||
const complete = available && !missing.length;
|
|
||||||
const relevant =
|
|
||||||
saved?.work.filter((work) =>
|
|
||||||
work.intervals.some((interval) =>
|
|
||||||
targets.some((target) => operationIntervalsOverlap(interval, target)),
|
|
||||||
),
|
|
||||||
) ?? [];
|
|
||||||
const work =
|
|
||||||
relevant.find((work) => work.state === 'running') ??
|
|
||||||
relevant.find((work) => work.state === 'failed') ??
|
|
||||||
null;
|
|
||||||
const sources = (saved?.coverage ?? []).filter((row) =>
|
|
||||||
targets.some(
|
|
||||||
(target) =>
|
|
||||||
operationIntervalsOverlap(row, target) &&
|
|
||||||
(!row.instrumentUid || row.instrumentUid === target.instrumentUid),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const lastSuccessfulAt =
|
|
||||||
sources
|
|
||||||
.map((row) => row.fetchedAt)
|
|
||||||
.sort((a, b) => a.valueOf() - b.valueOf())[0] ??
|
|
||||||
(complete ? saved?.connection.membershipAt : null) ??
|
|
||||||
null;
|
|
||||||
const historyTargets = accountHistory(accounts, now);
|
|
||||||
const history = planOperationRead(
|
|
||||||
historyTargets,
|
|
||||||
saved?.coverage ?? [],
|
|
||||||
now,
|
now,
|
||||||
{ movingEnd: true },
|
to,
|
||||||
);
|
work: saved?.work ?? [],
|
||||||
const knownBoundary = accounts.every(
|
});
|
||||||
(account) => account.openedOn !== null,
|
const { targets, plan, confirmed, missing, available, complete, lastSuccessfulAt, history, knownBoundary, work } = view;
|
||||||
);
|
|
||||||
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
||||||
const instruments = await this.metadata.savedMany(
|
const instruments = await this.metadata.savedMany(
|
||||||
rows.map((row) => row.payload as TbankOperationItem),
|
rows.map((row) => row.payload as TbankOperationItem),
|
||||||
@@ -252,49 +174,17 @@ export class BrokerageOperationsService {
|
|||||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membershipMeta, ...metadataDependencies],
|
dependencies: [membershipMeta, ...metadataDependencies],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serializeRange),
|
||||||
completeRanges: confirmed.map(serialize),
|
completeRanges: confirmed.map(serializeRange),
|
||||||
missingRanges: missing.map(serialize),
|
missingRanges: missing.map(serializeRange),
|
||||||
asOf:
|
asOf: coverageAsOf(plan, accounts.map((account) => account.accountId)),
|
||||||
accounts.length &&
|
|
||||||
accounts.every((account) =>
|
|
||||||
plan.intervals.some(
|
|
||||||
(interval) =>
|
|
||||||
interval.accountId === account.accountId && interval.asOf,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
? new Date(
|
|
||||||
Math.min(
|
|
||||||
...accounts.map((account) =>
|
|
||||||
Math.max(
|
|
||||||
...plan.intervals
|
|
||||||
.filter(
|
|
||||||
(interval) =>
|
|
||||||
interval.accountId === account.accountId,
|
|
||||||
)
|
|
||||||
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
).toISOString()
|
|
||||||
: null,
|
|
||||||
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
||||||
historyState: saved?.work.some(
|
historyState: historyState(
|
||||||
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
knownBoundary,
|
||||||
)
|
history,
|
||||||
? 'loading'
|
saved?.work ?? [],
|
||||||
: saved?.work.some(
|
saved?.coverage.length ?? NO_COVERAGE_ROWS,
|
||||||
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
),
|
||||||
)
|
|
||||||
? 'failed'
|
|
||||||
: knownBoundary &&
|
|
||||||
history.intervals.every(
|
|
||||||
(interval) => interval.availability === 'complete',
|
|
||||||
)
|
|
||||||
? 'complete'
|
|
||||||
: saved?.coverage.length
|
|
||||||
? 'partial'
|
|
||||||
: 'missing',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const last = rows.at(LAST_ROW_INDEX);
|
const last = rows.at(LAST_ROW_INDEX);
|
||||||
@@ -329,7 +219,7 @@ export class BrokerageOperationsService {
|
|||||||
hasNext,
|
hasNext,
|
||||||
nextCursor:
|
nextCursor:
|
||||||
hasNext && last && saved
|
hasNext && last && saved
|
||||||
? this.encodeCursor({
|
? this.cursorCodec.encode({
|
||||||
binding: saved.binding,
|
binding: saved.binding,
|
||||||
movingEnd,
|
movingEnd,
|
||||||
to: to.toISOString(),
|
to: to.toISOString(),
|
||||||
@@ -344,55 +234,10 @@ export class BrokerageOperationsService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private encodeCursor(value: LocalOperationsCursor): string {
|
|
||||||
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
|
|
||||||
return `${body}.${createHmac('sha256', this.config.getOrThrow<string>('JWT_SECRET')).update(body).digest('base64url')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private decodeCursor(value: string): LocalOperationsCursor {
|
|
||||||
try {
|
|
||||||
const [body, signature, extra] = value.split('.');
|
|
||||||
const expected = createHmac(
|
|
||||||
'sha256',
|
|
||||||
this.config.getOrThrow<string>('JWT_SECRET'),
|
|
||||||
)
|
|
||||||
.update(body)
|
|
||||||
.digest();
|
|
||||||
const actual = Buffer.from(signature ?? '', 'base64url');
|
|
||||||
if (
|
|
||||||
extra ||
|
|
||||||
actual.length !== expected.length ||
|
|
||||||
!timingSafeEqual(actual, expected)
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
const cursor = JSON.parse(
|
|
||||||
Buffer.from(body, 'base64url').toString(),
|
|
||||||
) as Partial<LocalOperationsCursor>;
|
|
||||||
if (
|
|
||||||
(cursor.movingEnd !== undefined &&
|
|
||||||
typeof cursor.movingEnd !== 'boolean') ||
|
|
||||||
!cursor.binding ||
|
|
||||||
!cursor.accountId ||
|
|
||||||
!cursor.id ||
|
|
||||||
!cursor.at ||
|
|
||||||
!cursor.to ||
|
|
||||||
!Number.isFinite(Date.parse(cursor.at)) ||
|
|
||||||
!Number.isFinite(Date.parse(cursor.to))
|
|
||||||
) {
|
|
||||||
throw new Error();
|
|
||||||
}
|
|
||||||
return cursor as LocalOperationsCursor;
|
|
||||||
} catch {
|
|
||||||
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async collectRanges(
|
async collectRanges(
|
||||||
userId: string,
|
userId: string,
|
||||||
accountId: string,
|
accountId: string,
|
||||||
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
||||||
observe = false,
|
|
||||||
) {
|
) {
|
||||||
const connection = await this.access.local(userId, accountId);
|
const connection = await this.access.local(userId, accountId);
|
||||||
const input = {
|
const input = {
|
||||||
@@ -406,34 +251,19 @@ export class BrokerageOperationsService {
|
|||||||
periods: ranges,
|
periods: ranges,
|
||||||
};
|
};
|
||||||
let saved = await this.repository.readPage(input);
|
let saved = await this.repository.readPage(input);
|
||||||
const schedule = async () => {
|
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||||
const current = await this.access.local(userId, accountId);
|
(saved?.periods ?? []).map((period) => ({
|
||||||
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) => ({
|
|
||||||
accountId: account.accountId,
|
accountId: account.accountId,
|
||||||
...period,
|
...period,
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
if (targets.length) {
|
if (targets.length) {
|
||||||
await this.sync.readPlan(userId, targets, {
|
await this.sync.readPlan(userId, targets, {
|
||||||
movingEnd: snapshot.periods?.some((period) => period.movingEnd),
|
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
||||||
history: true,
|
|
||||||
revision: connection.revision,
|
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);
|
saved = await this.repository.readPage(input);
|
||||||
}
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const hourly = await this.hourlySync.hourly(userId);
|
const hourly = await this.hourlySync.hourly(userId);
|
||||||
const membership = investmentResourceMeta({
|
const membership = investmentResourceMeta({
|
||||||
@@ -507,12 +337,12 @@ export class BrokerageOperationsService {
|
|||||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||||
dependencies: [membership],
|
dependencies: [membership],
|
||||||
coverage: {
|
coverage: {
|
||||||
requested: targets.map(serialize),
|
requested: targets.map(serializeRange),
|
||||||
completeRanges: confirmed.map(serialize),
|
completeRanges: confirmed.map(serializeRange),
|
||||||
missingRanges: uncoveredOperationIntervals(
|
missingRanges: uncoveredOperationIntervals(
|
||||||
plan.intervals,
|
plan.intervals,
|
||||||
confirmed,
|
confirmed,
|
||||||
).map(serialize),
|
).map(serializeRange),
|
||||||
asOf:
|
asOf:
|
||||||
complete && targets.length
|
complete && targets.length
|
||||||
? new Date(
|
? new Date(
|
||||||
@@ -582,7 +412,3 @@ export class BrokerageOperationsService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function serialize(range: { accountId: string; from: Date; to: Date }) {
|
|
||||||
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
|
||||||
}
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user