Compare commits
16
Commits
1ddebbdbad
..
v2.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
84433ee207 | ||
|
|
d792baaf59 | ||
|
|
83b515a453 | ||
|
|
07cfd08df9 | ||
|
|
e3ecb8c15e | ||
|
|
61c55256e7 | ||
|
|
cea77f6d56 | ||
|
|
b3d4801cfe | ||
|
|
b3ca11d141 | ||
|
|
efac7201e0 | ||
|
|
85a71967fa | ||
|
|
cd78b43783 | ||
|
|
deda7ffc2c | ||
|
|
5d31b3e6dd | ||
|
|
c5ccfeadea | ||
|
|
02e0d560c8 |
+6
-6
@@ -29,9 +29,9 @@ market — котировками, income — выплатами/личными
|
||||
domain из-за extraction в функцию. Boundary mappers преобразуют внешние records;
|
||||
чистые расчёты portfolio/banking остаются у domain owner.
|
||||
|
||||
PrismaService владеет DB lifecycle. Самостоятельные use cases получают данные
|
||||
через concrete repositories; существующие связные CRUD/transaction services
|
||||
сохраняют прямые queries по [data convention](conventions/data.md).
|
||||
PrismaService владеет DB lifecycle. Application services/use cases получают
|
||||
данные через concrete repositories; запросы и CAS живут в infrastructure
|
||||
по [data convention](conventions/data.md).
|
||||
Money — currency + decimal string; расчёты используют точные общие helpers.
|
||||
|
||||
Auth управляет identity/session; TbankAccessService проверяет доступ к счёту и
|
||||
@@ -201,9 +201,9 @@ Circular dependencies устраняй изменением ownership/compositio
|
||||
конкретные infrastructure providers, зарегистрированные module. Infrastructure
|
||||
может использовать application types/input и domain rules; новые interfaces
|
||||
ради инверсии зависимостей не обязательны. Domain не импортирует presentation или
|
||||
infrastructure. Существующие связные CRUD/transaction services могут сохранять прямые queries
|
||||
по [Data](conventions/data.md); use case получает данные через repository и сам
|
||||
Prisma queries не выполняет. Entity/interface на каждую таблицу не требуется.
|
||||
infrastructure. Application не строит Prisma queries: use case и связный CRUD
|
||||
service обращаются к репозиторию по [Data](conventions/data.md). Entity/interface
|
||||
на каждую таблицу не требуется.
|
||||
DTO остаются транспортными runtime classes; существующее использование DTO
|
||||
на application boundary допустимо по [HTTP API](conventions/http-api.md).
|
||||
|
||||
|
||||
+10
-10
@@ -34,19 +34,19 @@ candidate, не повод дублировать их при новом use cas
|
||||
вызывает repository, затем чистый `bankSummary` и формирует ответ. Prisma records
|
||||
не становятся domain entities: расчёт получает только нужные ему значения.
|
||||
|
||||
Не создавай обёртку на каждый вызов Prisma. Существующие связные CRUD/transaction
|
||||
services (auth, lifecycle подключения, изменения карт/операций/категорий)
|
||||
сохраняют queries рядом с CAS и проверками инварианта, если их перенос требует
|
||||
лишь набора pass-through методов. Новая самостоятельная read/write capability
|
||||
получает repository method; самостоятельный use case не выполняет Prisma queries.
|
||||
`BankCardsRepository.write` сохраняет общую retry/transaction boundary для
|
||||
существующих banking services. Такой callback с Prisma transaction client —
|
||||
локальная техническая граница этих services, не API для domain или use cases.
|
||||
Application-код не импортирует PrismaService и не строит Prisma queries.
|
||||
Репозиторий владеет запросом и CAS-проверкой результата, а application —
|
||||
правилами и формированием ответа. Типы Prisma допустимы только в type-only
|
||||
импортах для существующей транзакционной границы. Не создавай обёртку
|
||||
на каждый вызов Prisma без текущего потребителя. `BankCardsRepository.write`
|
||||
сохраняет retry/transaction boundary для банковских записей; callback с
|
||||
transaction client — локальная техническая граница, не API для domain.
|
||||
|
||||
Выбирай только нужные данные, сохраняй user/account ownership в запросах и
|
||||
application checks. Предварительный lookup не заменяет unique constraint при race.
|
||||
Пример: AuthService обрабатывает P2002 и использует conditional `updateMany`
|
||||
для refresh rotation. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||
Пример: AuthRepository преобразует P2002 в результат регистрации и выполняет
|
||||
conditional `updateMany` для refresh rotation; AuthService сохраняет HTTP
|
||||
error contract. Сначала пойми invariant; не копируй его flow в другой use case.
|
||||
|
||||
Transaction не охватывает сетевой T-Bank вызов как будто это одна atomic DB
|
||||
операция. Планируй порядок и compensation по текущей задаче; не удерживай DB
|
||||
|
||||
@@ -1,5 +1,41 @@
|
||||
# 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
|
||||
|
||||
@@ -53,11 +53,6 @@
|
||||
"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": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
@@ -101,7 +96,7 @@
|
||||
"count": 2
|
||||
},
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 9
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"src/modules/investments/operations/domain/operations-sync-plan.ts": {
|
||||
@@ -135,11 +130,6 @@
|
||||
"count": 1
|
||||
}
|
||||
},
|
||||
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 6
|
||||
}
|
||||
},
|
||||
"test/bank-cards-durability.cjs": {
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 1
|
||||
@@ -158,9 +148,6 @@
|
||||
"test/tbank-ledger-local.e2e-spec.ts": {
|
||||
"sonarjs/no-nested-conditional": {
|
||||
"count": 1
|
||||
},
|
||||
"sonarjs/no-nested-template-literals": {
|
||||
"count": 3
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
-17
@@ -15,6 +15,18 @@ import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
|
||||
const tests = ['**/*.spec.ts', 'test/**/*.ts'];
|
||||
const guardedModules = [
|
||||
{
|
||||
name: 'tbank-sync',
|
||||
except: [
|
||||
'./tbank-sync.api.ts',
|
||||
'./tbank-sync.dto.ts',
|
||||
'./tbank-sync.module.ts',
|
||||
'./tbank-sync-repository.module.ts',
|
||||
],
|
||||
},
|
||||
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
|
||||
];
|
||||
const prismaImports = [
|
||||
'@prisma/*', '**/generated/prisma/**', '**/prisma.service',
|
||||
];
|
||||
@@ -158,13 +170,14 @@ export default defineConfig(
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.use-case.ts'],
|
||||
files: ['src/**/application/**/*.ts'],
|
||||
ignores: tests,
|
||||
rules: {
|
||||
'no-restricted-imports': ['error', {
|
||||
patterns: [{
|
||||
group: prismaImports,
|
||||
message: 'Use cases obtain data through concrete repositories.',
|
||||
allowTypeImports: true,
|
||||
message: 'Application code obtains data through concrete repositories; type-only imports are allowed.',
|
||||
}],
|
||||
}],
|
||||
},
|
||||
@@ -212,24 +225,20 @@ export default defineConfig(
|
||||
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.ts'],
|
||||
ignores: [...tests, 'src/modules/investments/tbank-sync/**'],
|
||||
// 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: {
|
||||
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
|
||||
'import-x/no-restricted-paths': ['error', {
|
||||
zones: [{
|
||||
zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
|
||||
target: './src',
|
||||
from: './src/modules/investments/tbank-sync',
|
||||
except: [
|
||||
'./tbank-sync.api.ts',
|
||||
'./tbank-sync.dto.ts',
|
||||
'./tbank-sync.module.ts',
|
||||
'./tbank-sync-repository.module.ts',
|
||||
],
|
||||
message: 'Use tbank-sync.api for cross-module access.',
|
||||
}],
|
||||
from: `./src/modules/investments/${name}`,
|
||||
except,
|
||||
message: `Use ${name}.api for cross-module access.`,
|
||||
})),
|
||||
}],
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"license": "UNLICENSED",
|
||||
"dependencies": {
|
||||
"@grpc/grpc-js": "1.14.6",
|
||||
|
||||
+17
-3
@@ -1,9 +1,19 @@
|
||||
{
|
||||
"name": "wealth-ledger-backend",
|
||||
"version": "2.4.0",
|
||||
"version": "2.4.1",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
"imports": {
|
||||
"#generated/*": {
|
||||
"development": "./dist-dev/generated/*",
|
||||
"default": "./dist/generated/*"
|
||||
},
|
||||
"#src/*": {
|
||||
"development": "./dist-dev/src/*",
|
||||
"default": "./dist/src/*"
|
||||
}
|
||||
},
|
||||
"license": "UNLICENSED",
|
||||
"engines": {
|
||||
"node": "v24.22.1"
|
||||
@@ -14,8 +24,8 @@
|
||||
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
|
||||
"format": "npm run lint:fix",
|
||||
"start": "nest start",
|
||||
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
|
||||
"start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
|
||||
"start:prod": "node dist/src/main.js",
|
||||
"lint": "npm run lint:check",
|
||||
"lint:check": "eslint . --max-warnings=0",
|
||||
@@ -131,6 +141,10 @@
|
||||
}
|
||||
},
|
||||
"jest": {
|
||||
"moduleNameMapper": {
|
||||
"^#generated/(.*)$": "<rootDir>/../generated/$1",
|
||||
"^#src/(.*)$": "<rootDir>/$1"
|
||||
},
|
||||
"moduleFileExtensions": [
|
||||
"js",
|
||||
"json",
|
||||
|
||||
@@ -5,13 +5,15 @@ import { isDeepStrictEqual } from 'node:util';
|
||||
import type {
|
||||
TbankInstrumentLookup as MetadataLookupRow,
|
||||
TbankInstrument as MetadataRow,
|
||||
Prisma,
|
||||
} from '../../../generated/prisma/client';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { toJson } from '../../prisma/to-json';
|
||||
import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
@@ -119,11 +121,8 @@ export class InstrumentMetadataRepository {
|
||||
active: ReadonlySet<string>,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankConnection.updateMany({
|
||||
where: { ...attemptOwner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!valid.count) return null;
|
||||
const valid = await lockConnectionGeneration(tx, attemptOwner);
|
||||
if (!valid) return null;
|
||||
const scope = {
|
||||
...attemptOwner,
|
||||
scopeKey: `metadata:${metadataKey(identity)}`,
|
||||
@@ -167,15 +166,11 @@ export class InstrumentMetadataRepository {
|
||||
|
||||
publish(attempt: TbankSyncAttempt, results: MetadataLookup[], now: Date) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
const aliases = new Map(
|
||||
results.map((result) => [`${result.type}:${result.id}`, result]),
|
||||
);
|
||||
@@ -212,9 +207,7 @@ export class InstrumentMetadataRepository {
|
||||
const previous = await tx.tbankInstrument.findUnique({
|
||||
where: { provider_uid: key },
|
||||
});
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(instrument),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson(instrument);
|
||||
const data = {
|
||||
metadata: payload,
|
||||
figi:
|
||||
@@ -264,18 +257,17 @@ export class InstrumentMetadataRepository {
|
||||
}
|
||||
|
||||
async finishObserver(attempt: TbankSyncAttempt, success: boolean) {
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
...(success ? { connection: { invalidToken: false } } : {}),
|
||||
},
|
||||
data: {
|
||||
await claimSyncWork(
|
||||
this.prisma,
|
||||
attempt,
|
||||
{
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
{ withoutConnectionFence: !success },
|
||||
);
|
||||
if (success) {
|
||||
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||
await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
|
||||
@@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
const EXPECTED_UPDATED_CONNECTIONS = 1;
|
||||
@@ -56,11 +57,8 @@ export class TbankAccessService {
|
||||
force: boolean,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { userId, revision, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, { userId, revision });
|
||||
if (!current) return null;
|
||||
const connection = await tx.tbankConnection.findUniqueOrThrow({
|
||||
where: { userId },
|
||||
});
|
||||
@@ -110,15 +108,11 @@ export class TbankAccessService {
|
||||
|
||||
async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) {
|
||||
if (invalidToken) return this.rejectMembershipToken(attempt);
|
||||
const result = await this.prisma.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
const result = await claimSyncWork(this.prisma, attempt, {
|
||||
state: 'failed',
|
||||
errorCode: TbankErrorCode.Unavailable,
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
return result === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
|
||||
async tokenForAttempt(attempt: TbankSyncAttempt): Promise<null | string> {
|
||||
@@ -163,15 +157,8 @@ export class TbankAccessService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Acquire the write fence before reading membership. Replacement and
|
||||
// attempt supersession cannot interleave with the publication below.
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||
if (!claimed) return false;
|
||||
const { userId, revision } = attempt;
|
||||
const previous = await tx.tbankAccount.findMany({
|
||||
where: { userId, revision },
|
||||
@@ -244,11 +231,11 @@ export class TbankAccessService {
|
||||
|
||||
async rejectMembershipToken(attempt: TbankSyncAttempt): Promise<boolean> {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'failed',
|
||||
errorCode: TbankErrorCode.InvalidToken,
|
||||
}, { withoutConnectionFence: true });
|
||||
if (!claimed) return false;
|
||||
await tx.tbankConnection.update({
|
||||
where: {
|
||||
userId_revision: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -2,12 +2,14 @@ import { ConflictException, UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { createHash } from 'crypto';
|
||||
|
||||
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||
|
||||
jest.mock('bcrypt', () => ({
|
||||
hash: jest.fn(() => Promise.resolve('password-hash')),
|
||||
compare: jest.fn(() => Promise.resolve(true)),
|
||||
}));
|
||||
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
@@ -51,7 +53,7 @@ describe('AuthService', () => {
|
||||
delete: jest.fn(),
|
||||
},
|
||||
};
|
||||
const service = new AuthService(prisma as never, jwt);
|
||||
const service = new AuthService(new AuthRepository(prisma as never), jwt);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
@@ -7,15 +7,14 @@ import { JwtService } from '@nestjs/jwt';
|
||||
import * as bcrypt from 'bcrypt';
|
||||
import { createHash, randomUUID } from 'crypto';
|
||||
|
||||
import { Session, User } from '../../../../generated/prisma/client';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import type { Session, User } from '#generated/prisma/client';
|
||||
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { AuthRepository } from '../infrastructure/auth.repository';
|
||||
import { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
const PASSWORD_HASH_ROUNDS = 12;
|
||||
export const REFRESH_TTL_MS = 604_800_000;
|
||||
|
||||
@@ -28,7 +27,7 @@ const DUMMY_PASSWORD_HASH =
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly repository: AuthRepository,
|
||||
private readonly jwt: JwtService,
|
||||
) {}
|
||||
|
||||
@@ -36,27 +35,18 @@ export class AuthService {
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthenticationResult> {
|
||||
const existing = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
const existing = await this.repository.findUserByEmail(email);
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
|
||||
let user: User;
|
||||
try {
|
||||
user = await this.prisma.user.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
throw error;
|
||||
const user = await this.repository.createUser({
|
||||
id: randomUUID(),
|
||||
email,
|
||||
passwordHash: await bcrypt.hash(password, PASSWORD_HASH_ROUNDS),
|
||||
});
|
||||
if (!user) {
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
@@ -65,9 +55,7 @@ export class AuthService {
|
||||
email: string,
|
||||
password: string,
|
||||
): Promise<AuthenticationResult> {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email },
|
||||
});
|
||||
const user = await this.repository.findUserByEmail(email);
|
||||
const valid = await bcrypt.compare(
|
||||
password,
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
@@ -92,10 +80,7 @@ export class AuthService {
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
include: { user: true },
|
||||
});
|
||||
const session = await this.repository.findSessionWithUser(payload.sid);
|
||||
if (
|
||||
!session ||
|
||||
session.userId !== payload.sub ||
|
||||
@@ -103,7 +88,7 @@ export class AuthService {
|
||||
session.expiresAt <= new Date()
|
||||
) {
|
||||
if (session && !session.revokedAt) {
|
||||
await this.prisma.session.delete({ where: { id: session.id } });
|
||||
await this.repository.deleteSession(session.id);
|
||||
}
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
@@ -124,18 +109,13 @@ export class AuthService {
|
||||
return;
|
||||
}
|
||||
if (payload.type !== 'refresh') return;
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
});
|
||||
const session = await this.repository.findSession(payload.sid);
|
||||
if (
|
||||
session &&
|
||||
!session.revokedAt &&
|
||||
session.refreshTokenHash === this.hash(token)
|
||||
) {
|
||||
await this.prisma.session.update({
|
||||
where: { id: session.id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
await this.repository.revokeSession(session.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +124,7 @@ export class AuthService {
|
||||
}
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||
const user = await this.repository.findUserById(id);
|
||||
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
return user;
|
||||
}
|
||||
@@ -152,14 +132,7 @@ export class AuthService {
|
||||
private async createSession(
|
||||
user: User,
|
||||
): Promise<AuthenticationResult> {
|
||||
const session = await this.prisma.session.create({
|
||||
data: {
|
||||
id: randomUUID(),
|
||||
userId: user.id,
|
||||
refreshTokenHash: '',
|
||||
expiresAt: new Date(),
|
||||
},
|
||||
});
|
||||
const session = await this.repository.createSession(user.id, randomUUID());
|
||||
return this.rotateSession({ ...session, user });
|
||||
}
|
||||
|
||||
@@ -180,16 +153,8 @@ export class AuthService {
|
||||
refreshTokenHash: this.hash(refreshToken),
|
||||
expiresAt: new Date(Date.now() + REFRESH_TTL_MS),
|
||||
};
|
||||
if (expectedRefreshTokenHash) {
|
||||
const updated = await this.prisma.session.updateMany({
|
||||
where: { id: session.id, refreshTokenHash: expectedRefreshTokenHash },
|
||||
data,
|
||||
});
|
||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
if (!(await this.repository.rotateSession(session.id, data, expectedRefreshTokenHash))) {
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return {
|
||||
user: this.toUser(session.user),
|
||||
@@ -205,12 +170,3 @@ export class AuthService {
|
||||
return createHash('sha256').update(token).digest('hex');
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { PassportModule } from '@nestjs/passport';
|
||||
|
||||
import { Environment } from '../../config/env.validation';
|
||||
import { AuthService } from './application/auth.service';
|
||||
import { AuthRepository } from './infrastructure/auth.repository';
|
||||
import { JwtStrategy } from './infrastructure/jwt.strategy';
|
||||
import { AuthController } from './presentation/auth.controller';
|
||||
import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
@@ -22,6 +23,6 @@ import { JwtAuthGuard } from './presentation/jwt-auth.guard';
|
||||
],
|
||||
exports: [JwtAuthGuard],
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, JwtStrategy, JwtAuthGuard],
|
||||
providers: [AuthService, AuthRepository, JwtStrategy, JwtAuthGuard],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
|
||||
@Injectable()
|
||||
export class AuthRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
findUserByEmail(email: string) {
|
||||
return this.prisma.user.findUnique({ where: { email } });
|
||||
}
|
||||
|
||||
async createUser(data: { email: string; id: string; passwordHash: string }) {
|
||||
try {
|
||||
return await this.prisma.user.create({ data });
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
findUserById(id: string) {
|
||||
return this.prisma.user.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
findSessionWithUser(id: string) {
|
||||
return this.prisma.session.findUnique({
|
||||
where: { id },
|
||||
include: { user: true },
|
||||
});
|
||||
}
|
||||
|
||||
findSession(id: string) {
|
||||
return this.prisma.session.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
deleteSession(id: string) {
|
||||
return this.prisma.session.delete({ where: { id } });
|
||||
}
|
||||
|
||||
revokeSession(id: string) {
|
||||
return this.prisma.session.update({
|
||||
where: { id },
|
||||
data: { revokedAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
createSession(userId: string, id: string) {
|
||||
return this.prisma.session.create({
|
||||
data: { id, userId, refreshTokenHash: '', expiresAt: new Date() },
|
||||
});
|
||||
}
|
||||
|
||||
async rotateSession(
|
||||
id: string,
|
||||
data: { expiresAt: Date; refreshTokenHash: string },
|
||||
expectedRefreshTokenHash?: string,
|
||||
): Promise<boolean> {
|
||||
if (expectedRefreshTokenHash) {
|
||||
const updated = await this.prisma.session.updateMany({
|
||||
where: { id, refreshTokenHash: expectedRefreshTokenHash },
|
||||
data,
|
||||
});
|
||||
return updated.count === EXPECTED_ROTATED_SESSIONS;
|
||||
}
|
||||
await this.prisma.session.update({ where: { id }, data });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): boolean {
|
||||
return (
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'code' in error &&
|
||||
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||
);
|
||||
}
|
||||
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
import { Environment } from '#src/config/env.validation';
|
||||
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@Injectable()
|
||||
|
||||
@@ -20,8 +20,9 @@ import {
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '#src/common/error-code';
|
||||
|
||||
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
|
||||
import { AccessPayload, AuthenticationResult } from '../application/auth.types';
|
||||
import { LoginDto, RegisterDto } from './auth.dto';
|
||||
|
||||
@@ -5,7 +5,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import {
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
MAX_OPERATION_DESCRIPTION_LENGTH,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { BankCard, Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { BankCard, Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { bankBalance } from '../domain/bank-card.calculations';
|
||||
import { rubMoney } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
import { GoneException, Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
import type {
|
||||
BankCardOperation,
|
||||
Prisma,
|
||||
} from '../../../../../generated/prisma/client';
|
||||
} from '#generated/prisma/client';
|
||||
|
||||
import { FIRST_PAGE } from '../domain/bank-card-limits';
|
||||
import { BankOperationKind } from '../domain/bank-operation-kind';
|
||||
import {
|
||||
operationFieldConflict,
|
||||
operationKindChanged,
|
||||
} from '../domain/bank-operation.rules';
|
||||
import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money';
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import {
|
||||
@@ -36,46 +41,7 @@ export class BankOperationsService {
|
||||
tx: Tx,
|
||||
row: BankCardOperation,
|
||||
): Promise<BankOperationDto> {
|
||||
const card = await this.repository.card(tx, row.userId, row.cardId);
|
||||
const target = row.targetCardId
|
||||
? await this.repository.card(tx, row.userId, row.targetCardId)
|
||||
: null;
|
||||
const source = row.originalExpenseId
|
||||
? await this.repository.operation(tx, row.userId, row.originalExpenseId)
|
||||
: null;
|
||||
const categoryId = source?.categoryId ?? row.categoryId;
|
||||
const category = categoryId
|
||||
? await this.repository.category(tx, row.userId, categoryId)
|
||||
: null;
|
||||
const refunds = [] as Awaited<ReturnType<BankCardsRepository['refunds']>>;
|
||||
if (row.kind === BankOperationKind.Expense || source) {
|
||||
refunds.push(...await this.repository.refunds(
|
||||
tx, row.userId, source?.id ?? row.id, source?.id ? row.id : undefined,
|
||||
));
|
||||
}
|
||||
return {
|
||||
id: row.id,
|
||||
kind: row.kind as BankOperationDto['kind'],
|
||||
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(),
|
||||
};
|
||||
return (await this.pageViews(tx, [row]))[0];
|
||||
}
|
||||
|
||||
private async pageViews(tx: Tx, rows: BankCardOperation[]) {
|
||||
@@ -150,25 +116,12 @@ export class BankOperationsService {
|
||||
fail(BankErrorCode.InvalidDate);
|
||||
}
|
||||
}
|
||||
if (
|
||||
old &&
|
||||
(old.kind !== input.kind ||
|
||||
old.originalExpenseId !== input.originalExpenseId)
|
||||
) {
|
||||
if (old && operationKindChanged(old.kind, input.kind,
|
||||
old.originalExpenseId, input.originalExpenseId)) {
|
||||
fail(BankErrorCode.ImmutableOperation);
|
||||
}
|
||||
if (input.kind === BankOperationKind.Transfer) {
|
||||
if (
|
||||
!input.targetCardId ||
|
||||
input.targetCardId === input.cardId ||
|
||||
input.categoryId ||
|
||||
input.originalExpenseId
|
||||
) {
|
||||
fail();
|
||||
}
|
||||
} else if (input.targetCardId) fail();
|
||||
if (operationFieldConflict(input)) fail();
|
||||
if (input.kind === BankOperationKind.Expense) {
|
||||
if (input.originalExpenseId) fail();
|
||||
if (!input.categoryId) {
|
||||
const category = await this.repository.defaultCategory(tx, userId);
|
||||
if (!category) missing();
|
||||
@@ -179,16 +132,12 @@ export class BankOperationsService {
|
||||
) {
|
||||
missing();
|
||||
}
|
||||
} else if (input.categoryId) fail();
|
||||
if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) {
|
||||
fail();
|
||||
}
|
||||
if (input.kind === BankOperationKind.Refund) {
|
||||
if (!input.originalExpenseId) fail();
|
||||
const source = await this.repository.operation(
|
||||
tx,
|
||||
userId,
|
||||
input.originalExpenseId,
|
||||
input.originalExpenseId as string,
|
||||
);
|
||||
if (
|
||||
source.deletedAt ||
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
|
||||
import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
import { ZERO_KOPECKS } from './rub-money';
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
import {
|
||||
operationFieldConflict,
|
||||
OperationFieldRule,
|
||||
operationKindChanged,
|
||||
} from './bank-operation.rules';
|
||||
|
||||
const cardId = 'card-1';
|
||||
|
||||
function fields(
|
||||
overrides: Partial<Parameters<typeof operationFieldConflict>[0]> = {},
|
||||
) {
|
||||
return { kind: BankOperationKind.Topup, cardId, ...overrides };
|
||||
}
|
||||
|
||||
describe('operationFieldConflict', () => {
|
||||
it('allows a valid topup', () => {
|
||||
expect(operationFieldConflict(fields())).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a topup with a purchase link', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({ originalExpenseId: 'op-1' }),
|
||||
),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
});
|
||||
|
||||
it('rejects a non-transfer with a target card', () => {
|
||||
expect(
|
||||
operationFieldConflict(fields({ targetCardId: 'card-2' })),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
});
|
||||
|
||||
describe('expense', () => {
|
||||
it('allows the default category to be resolved by the service', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({ kind: BankOperationKind.Expense }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
it('rejects a purchase link', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({
|
||||
kind: BankOperationKind.Expense,
|
||||
categoryId: 'cat-1',
|
||||
originalExpenseId: 'op-1',
|
||||
}),
|
||||
),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
});
|
||||
|
||||
it('allows a plain categorized expense', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({ kind: BankOperationKind.Expense, categoryId: 'cat-1' }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transfer', () => {
|
||||
it('requires a target card', () => {
|
||||
expect(
|
||||
operationFieldConflict(fields({ kind: BankOperationKind.Transfer })),
|
||||
).toBe(OperationFieldRule.MissingTargetCard);
|
||||
});
|
||||
|
||||
it('rejects a transfer to the same card', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({
|
||||
kind: BankOperationKind.Transfer,
|
||||
targetCardId: cardId,
|
||||
}),
|
||||
),
|
||||
).toBe(OperationFieldRule.SameTargetCard);
|
||||
});
|
||||
|
||||
it('rejects a transfer with a category or purchase link', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({
|
||||
kind: BankOperationKind.Transfer,
|
||||
targetCardId: 'card-2',
|
||||
categoryId: 'cat-1',
|
||||
}),
|
||||
),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({
|
||||
kind: BankOperationKind.Transfer,
|
||||
targetCardId: 'card-2',
|
||||
originalExpenseId: 'op-1',
|
||||
}),
|
||||
),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refund', () => {
|
||||
it('requires a purchase link', () => {
|
||||
expect(
|
||||
operationFieldConflict(fields({ kind: BankOperationKind.Refund })),
|
||||
).toBe(OperationFieldRule.MissingOriginalExpense);
|
||||
});
|
||||
|
||||
it('rejects a category', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({
|
||||
kind: BankOperationKind.Refund,
|
||||
originalExpenseId: 'op-1',
|
||||
categoryId: 'cat-1',
|
||||
}),
|
||||
),
|
||||
).toBe(OperationFieldRule.InvalidCombination);
|
||||
});
|
||||
|
||||
it('allows a linked refund', () => {
|
||||
expect(
|
||||
operationFieldConflict(
|
||||
fields({ kind: BankOperationKind.Refund, originalExpenseId: 'op-1' }),
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('operationKindChanged', () => {
|
||||
it('flags kind and purchase link changes', () => {
|
||||
expect(operationKindChanged('expense', 'expense', 'op-1', 'op-1')).toBe(false);
|
||||
expect(operationKindChanged('expense', 'refund', null, null)).toBe(true);
|
||||
expect(operationKindChanged('refund', 'refund', 'op-1', null)).toBe(true);
|
||||
expect(operationKindChanged('refund', 'refund', null, 'op-1')).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { BankOperationKind } from './bank-operation-kind';
|
||||
|
||||
export type OperationFields = {
|
||||
cardId: string;
|
||||
categoryId?: null | string;
|
||||
kind: BankOperationKind;
|
||||
originalExpenseId?: null | string;
|
||||
targetCardId?: null | string;
|
||||
};
|
||||
|
||||
export enum OperationFieldRule {
|
||||
InvalidCombination = 'invalidCombination',
|
||||
MissingOriginalExpense = 'missingOriginalExpense',
|
||||
MissingTargetCard = 'missingTargetCard',
|
||||
SameTargetCard = 'sameTargetCard',
|
||||
}
|
||||
|
||||
function transferConflict(fields: OperationFields): null | OperationFieldRule {
|
||||
if (!fields.targetCardId) {
|
||||
return OperationFieldRule.MissingTargetCard;
|
||||
}
|
||||
if (fields.targetCardId === fields.cardId) {
|
||||
return OperationFieldRule.SameTargetCard;
|
||||
}
|
||||
if (fields.categoryId || fields.originalExpenseId) {
|
||||
return OperationFieldRule.InvalidCombination;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function expenseConflict(fields: OperationFields): null | OperationFieldRule {
|
||||
if (fields.originalExpenseId) {
|
||||
return OperationFieldRule.InvalidCombination;
|
||||
}
|
||||
// Expense without a category uses the user's default category in the service.
|
||||
return null;
|
||||
}
|
||||
|
||||
function refundConflict(fields: OperationFields): null | OperationFieldRule {
|
||||
return fields.originalExpenseId
|
||||
? null
|
||||
: OperationFieldRule.MissingOriginalExpense;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure kind/field compatibility rules for bank operations; no Prisma or Nest.
|
||||
* Returns the first violated rule, or null when the combination is allowed.
|
||||
*/
|
||||
export function operationFieldConflict(
|
||||
fields: OperationFields,
|
||||
): null | OperationFieldRule {
|
||||
if (fields.kind === BankOperationKind.Transfer) {
|
||||
return transferConflict(fields);
|
||||
}
|
||||
if (fields.targetCardId) return OperationFieldRule.InvalidCombination;
|
||||
if (fields.kind === BankOperationKind.Expense) {
|
||||
return expenseConflict(fields);
|
||||
}
|
||||
if (fields.categoryId) return OperationFieldRule.InvalidCombination;
|
||||
if (fields.kind === BankOperationKind.Refund) {
|
||||
return refundConflict(fields);
|
||||
}
|
||||
// Topup: no target, no category, no purchase link.
|
||||
return fields.originalExpenseId
|
||||
? OperationFieldRule.InvalidCombination
|
||||
: null;
|
||||
}
|
||||
|
||||
export function operationKindChanged(
|
||||
oldKind: string,
|
||||
newKind: string,
|
||||
oldOriginalExpenseId: null | string,
|
||||
newOriginalExpenseId: null | string,
|
||||
): boolean {
|
||||
return (
|
||||
oldKind !== newKind ||
|
||||
oldOriginalExpenseId !== newOriginalExpenseId
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||
|
||||
export const ZERO_KOPECKS = 0n;
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
BankErrorCode,
|
||||
conflict,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ApiProperty,
|
||||
ApiPropertyOptional,
|
||||
getSchemaPath,
|
||||
IntersectionType,
|
||||
OmitType,
|
||||
PartialType,
|
||||
@@ -23,7 +24,8 @@ import {
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
FIRST_PAGE,
|
||||
MAX_CARD_NAME_LENGTH,
|
||||
@@ -153,6 +155,49 @@ export class CreateBankOperationDto {
|
||||
@MaxLength(MAX_OPERATION_DESCRIPTION_LENGTH)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
// OpenAPI-only variants. Runtime validation stays on CreateBankOperationDto so
|
||||
// invalid combinations keep the existing BANK_INVALID_INPUT response.
|
||||
class BankOperationCreateBaseDto extends OmitType(CreateBankOperationDto, [
|
||||
'kind', 'targetCardId', 'categoryId', 'originalExpenseId',
|
||||
] as const) {}
|
||||
|
||||
export class CreateExpenseOperationDto extends BankOperationCreateBaseDto {
|
||||
@ApiProperty({ enum: [BankOperationKind.Expense] }) declare kind: 'expense';
|
||||
@ApiPropertyOptional({ format: 'uuid' }) declare categoryId?: string;
|
||||
}
|
||||
|
||||
export class CreateTopupOperationDto extends BankOperationCreateBaseDto {
|
||||
@ApiProperty({ enum: [BankOperationKind.Topup] }) declare kind: 'topup';
|
||||
}
|
||||
|
||||
export class CreateTransferOperationDto extends BankOperationCreateBaseDto {
|
||||
@ApiProperty({ enum: [BankOperationKind.Transfer] }) declare kind: 'transfer';
|
||||
@ApiProperty({ format: 'uuid' }) declare targetCardId: string;
|
||||
}
|
||||
|
||||
export class CreateRefundOperationDto extends BankOperationCreateBaseDto {
|
||||
@ApiProperty({ enum: [BankOperationKind.Refund] }) declare kind: 'refund';
|
||||
@ApiProperty({ format: 'uuid' }) declare originalExpenseId: string;
|
||||
}
|
||||
|
||||
export const createBankOperationSchema = {
|
||||
oneOf: [
|
||||
CreateExpenseOperationDto,
|
||||
CreateTopupOperationDto,
|
||||
CreateTransferOperationDto,
|
||||
CreateRefundOperationDto,
|
||||
].map((model) => ({ $ref: getSchemaPath(model) })),
|
||||
discriminator: {
|
||||
propertyName: 'kind',
|
||||
mapping: {
|
||||
expense: getSchemaPath(CreateExpenseOperationDto),
|
||||
topup: getSchemaPath(CreateTopupOperationDto),
|
||||
transfer: getSchemaPath(CreateTransferOperationDto),
|
||||
refund: getSchemaPath(CreateRefundOperationDto),
|
||||
},
|
||||
},
|
||||
};
|
||||
export class UpdateBankOperationDto extends PartialType(
|
||||
OmitType(CreateBankOperationDto, ['id'] as const),
|
||||
{ skipNullProperties: false },
|
||||
|
||||
@@ -16,7 +16,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiCreatedResponse,
|
||||
ApiExtraModels,
|
||||
ApiNoContentResponse,
|
||||
ApiOkResponse,
|
||||
ApiResponse,
|
||||
@@ -27,8 +29,9 @@ import {
|
||||
import {
|
||||
ApiProblemResponses,
|
||||
ProblemDetailsDto,
|
||||
} from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
} from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BankCardsService } from '../application/bank-cards.service';
|
||||
import { BankOperationsService } from '../application/bank-operations.service';
|
||||
import { ExpenseCategoriesService } from '../application/expense-categories.service';
|
||||
@@ -46,6 +49,11 @@ import {
|
||||
CategoryNameDto,
|
||||
CreateBankCardDto,
|
||||
CreateBankOperationDto,
|
||||
createBankOperationSchema,
|
||||
CreateExpenseOperationDto,
|
||||
CreateRefundOperationDto,
|
||||
CreateTopupOperationDto,
|
||||
CreateTransferOperationDto,
|
||||
ExpenseCategoryDto,
|
||||
UpdateBankCardDto,
|
||||
UpdateBankOperationDto,
|
||||
@@ -73,6 +81,12 @@ type AuthRequest = Request & { user: AccessPayload };
|
||||
},
|
||||
})
|
||||
@ApiTags('bank-cards')
|
||||
@ApiExtraModels(
|
||||
CreateExpenseOperationDto,
|
||||
CreateTopupOperationDto,
|
||||
CreateTransferOperationDto,
|
||||
CreateRefundOperationDto,
|
||||
)
|
||||
@ApiBearerAuth()
|
||||
@ApiProblemResponses()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -131,6 +145,7 @@ export class BankCardsController {
|
||||
}
|
||||
|
||||
@Post('operations')
|
||||
@ApiBody({ schema: createBankOperationSchema })
|
||||
@ApiCreatedResponse({ type: BankOperationDto })
|
||||
createOperation(
|
||||
@Req() req: AuthRequest,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
import { PortfolioStructureDto } from '../portfolio/portfolio.api';
|
||||
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
|
||||
import { BrokerageAccountsService } from './brokerage-accounts.service';
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { 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 {},
|
||||
}));
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
formatDecimal,
|
||||
isZero,
|
||||
multiply,
|
||||
} from '../../../common/decimal';
|
||||
} from '#src/common/decimal';
|
||||
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
|
||||
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
|
||||
|
||||
@@ -3,9 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
|
||||
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../investment-resource.dto';
|
||||
import {
|
||||
IncomeAnalyticsQueryDto,
|
||||
IncomeAnalyticsResourceDto,
|
||||
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
|
||||
@ApiOkResponse({ type: IncomeSummaryResourceDto })
|
||||
allSummary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
'all',
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
|
||||
'all',
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
|
||||
summary(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('accountId') accountId: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.summary(
|
||||
request.user.sub,
|
||||
accountId,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
|
||||
accountId,
|
||||
query.from,
|
||||
query.to,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, Matches } from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
InvestmentDependencyDto,
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../investment-resource.dto';
|
||||
import { BrokerageOperationDto } from '../operations/operations.api';
|
||||
|
||||
const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
|
||||
|
||||
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto {
|
||||
export class IncomeAnalyticsQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
format: 'date',
|
||||
description:
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
jest.mock('../operations/application/brokerage-operations.service', () => ({
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
compare,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
@@ -43,14 +44,12 @@ export class IncomeAnalyticsService {
|
||||
async summary(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
observe = false,
|
||||
): Promise<IncomeSummaryResourceDto> {
|
||||
const plan = planIncomePeriod(undefined, undefined);
|
||||
const [selected] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
if (selected.items === null) return { data: null, meta: selected.meta };
|
||||
assert(selected.meta.lastSuccessfulAt);
|
||||
@@ -70,14 +69,12 @@ export class IncomeAnalyticsService {
|
||||
accountId: string,
|
||||
from: string | undefined,
|
||||
to: string | undefined,
|
||||
observe = false,
|
||||
): Promise<IncomeAnalyticsResourceDto> {
|
||||
const plan = planIncomePeriod(from, to);
|
||||
const [current] = await this.operations.collectRanges(
|
||||
userId,
|
||||
accountId,
|
||||
() => [plan.selected],
|
||||
observe,
|
||||
);
|
||||
const dependencies: IncomeDependencyDto[] = [
|
||||
{ ...current.meta, role: 'selected' },
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
import {
|
||||
DateOnlyRange,
|
||||
dateParts,
|
||||
|
||||
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
|
||||
jest.mock('./catalog-enrichment.service', () => ({
|
||||
CatalogEnrichmentService: class {},
|
||||
}));
|
||||
jest.mock('../../../../integrations/tbank/tbank.api', () => ({
|
||||
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
TbankClient: class {},
|
||||
}));
|
||||
|
||||
@@ -5,15 +5,16 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
candleGaps,
|
||||
@@ -98,7 +99,7 @@ export class CandleService {
|
||||
private readonly logger: StructuredLogger,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CandleInput, observe = false) {
|
||||
async read(userId: string, input: CandleInput) {
|
||||
const { revision } = await this.access.local(userId);
|
||||
const [detail] = await this.sources.readMany(
|
||||
userId,
|
||||
@@ -118,7 +119,7 @@ export class CandleService {
|
||||
const hasCompleteRange = Boolean(
|
||||
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);
|
||||
}
|
||||
const work = await this.repository.work(userId, revision, scope(input));
|
||||
|
||||
@@ -8,16 +8,15 @@ import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
type CatalogQuery,
|
||||
catalogScope,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import {
|
||||
catalogSearchLimit,
|
||||
selectCatalogInstruments,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const UNAMBIGUOUS_MATCH_COUNT = 1;
|
||||
const NO_MATCHES = 0;
|
||||
const FIRST_RESULT_OFFSET = 0;
|
||||
|
||||
const tickerAmbiguityLimit = 2;
|
||||
@@ -33,203 +31,56 @@ const tickerAmbiguityLimit = 2;
|
||||
@Injectable()
|
||||
export class CatalogDiscoveryService {
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly enrichment: CatalogEnrichmentService,
|
||||
) {}
|
||||
|
||||
async read(userId: string, input: CatalogQuery) {
|
||||
input = 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 search(userId: string, input: CatalogQuery) {
|
||||
return this.onlineSearch(userId, catalogQuery(input));
|
||||
}
|
||||
|
||||
async detail(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
observe = false,
|
||||
) {
|
||||
if (!observe) {
|
||||
const instrument = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.getShare(token, uid)
|
||||
: this.tbank.getBond(token, uid),
|
||||
);
|
||||
if (!instrument) throw new NotFoundException();
|
||||
return {
|
||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
freshForMs: tbankSourceTtlMs.portfolio,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
const sources = await this.enrichment.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
['detail'],
|
||||
const instrument = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.getShare(token, uid)
|
||||
: this.tbank.getBond(token, uid),
|
||||
);
|
||||
const source = sources[0];
|
||||
if (source.meta.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);
|
||||
}
|
||||
if (!instrument) throw new NotFoundException();
|
||||
return {
|
||||
data: instrument ? { ...instrument, uid, slug } : null,
|
||||
data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
|
||||
meta: investmentResourceMeta({
|
||||
resourceKey: `${instrumentType}/detail/${uid}`,
|
||||
dataRevision: dependencies
|
||||
.map((dependency) => dependency.dataRevision)
|
||||
.join('/'),
|
||||
availability: source.meta.availability,
|
||||
fetchedAt: source.meta.lastSuccessfulAt
|
||||
? new Date(source.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
dataRevision: `online:${Date.now()}`,
|
||||
availability: 'complete',
|
||||
fetchedAt: new Date(),
|
||||
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,
|
||||
work: null,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
|
||||
async byTicker(
|
||||
userId: string,
|
||||
instrumentType: 'bond' | 'share',
|
||||
ticker: string,
|
||||
observe = false,
|
||||
) {
|
||||
if (!observe) {
|
||||
const query = ticker.trim().toUpperCase();
|
||||
const found = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.findShares(token, query)
|
||||
: this.tbank.findBonds(token, query),
|
||||
);
|
||||
const matches = selectCatalogInstruments(
|
||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||
query,
|
||||
instrumentType,
|
||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||
if (!matches.length) throw new NotFoundException();
|
||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
? InstrumentErrorCode.StockTickerAmbiguous
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||
}
|
||||
const lookup = await this.read(userId, {
|
||||
const query = ticker.trim().toUpperCase();
|
||||
const found = await this.access.online(userId, ({ token }) =>
|
||||
instrumentType === 'share'
|
||||
? this.tbank.findShares(token, query)
|
||||
: this.tbank.findBonds(token, query),
|
||||
);
|
||||
const matches = selectCatalogInstruments(
|
||||
found.filter((item) => item.ticker?.trim().toUpperCase() === query),
|
||||
query,
|
||||
instrumentType,
|
||||
query: ticker,
|
||||
exactTicker: true,
|
||||
});
|
||||
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
|
||||
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
|
||||
if (!matches.length) throw new NotFoundException();
|
||||
if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
@@ -237,34 +88,8 @@ export class CatalogDiscoveryService {
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = lookup.data
|
||||
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe)
|
||||
: null;
|
||||
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
|
||||
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,
|
||||
}),
|
||||
};
|
||||
const result = await this.detail(userId, instrumentType, matches[0].uid);
|
||||
return { ...result, data: result.data ? { ...result.data, slug: query } : null };
|
||||
}
|
||||
|
||||
private async onlineSearch(userId: string, input: CatalogQuery) {
|
||||
|
||||
@@ -1,43 +1,26 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
} from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import {
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_SECOND,
|
||||
} from '../../../../common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
TbankClient,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
TbankLastPrice,
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogScope,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { type CatalogPayload, CatalogSourceLoader } from './catalog-source.loader';
|
||||
export type { CatalogPayload } from './catalog-source.loader';
|
||||
import { catalogSearchLimit } from './catalog-selection';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
const MISSING_DATA_REVISION = 0;
|
||||
@@ -93,9 +76,7 @@ export class CatalogEnrichmentService {
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly logger: StructuredLogger,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly ratings: NcrRatingsClient,
|
||||
private readonly candles: CandleRepository,
|
||||
private readonly loader: CatalogSourceLoader,
|
||||
) {}
|
||||
|
||||
async readMany(
|
||||
@@ -241,9 +222,8 @@ export class CatalogEnrichmentService {
|
||||
instrumentType: 'bond' | 'share',
|
||||
uid: string,
|
||||
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 instrument =
|
||||
instrumentType === 'share'
|
||||
@@ -273,25 +253,22 @@ export class CatalogEnrichmentService {
|
||||
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
const kinds: CatalogSource['kind'][] =
|
||||
section === 'company'
|
||||
? ['detail', 'description', 'fundamentals']
|
||||
: [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
];
|
||||
const kinds: CatalogSource['kind'][] = [
|
||||
...(instrumentType === 'bond'
|
||||
? ['bond-current' as const]
|
||||
: ['detail' as const]),
|
||||
'last-price',
|
||||
'trading-status',
|
||||
'comparison',
|
||||
...(section === 'metrics'
|
||||
? ['coupons' as const, 'yield' as const, 'rating' as const]
|
||||
: []),
|
||||
];
|
||||
const sources = await this.readMany(
|
||||
userId,
|
||||
instrumentType,
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
kinds,
|
||||
);
|
||||
const first = sources[0];
|
||||
@@ -319,7 +296,7 @@ export class CatalogEnrichmentService {
|
||||
fetchedAt: first.meta.lastSuccessfulAt
|
||||
? new Date(first.meta.lastSuccessfulAt)
|
||||
: null,
|
||||
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote,
|
||||
freshForMs: tbankSourceTtlMs.quote,
|
||||
work: null,
|
||||
dependencies,
|
||||
});
|
||||
@@ -337,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.
|
||||
private async drain() {
|
||||
try {
|
||||
@@ -537,7 +351,7 @@ export class CatalogEnrichmentService {
|
||||
batch.map((item) => item.input.uid),
|
||||
);
|
||||
} else {
|
||||
payload = await this.load(producer.input, token, producer.attempt);
|
||||
payload = await this.loader.load(producer.input, token, producer.attempt);
|
||||
}
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
@@ -639,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']) {
|
||||
if (
|
||||
@@ -679,6 +475,3 @@ function producerKey(input: CatalogSource) {
|
||||
? `bond-metadata:${input.uid}`
|
||||
: catalogScope(input);
|
||||
}
|
||||
|
||||
const quotationScale = 9;
|
||||
const quotationUnits = 1_000_000_000n;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
export const catalogSearchLimit = 30;
|
||||
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import {
|
||||
decimalFromString,
|
||||
divide,
|
||||
} from '#src/common/decimal';
|
||||
import {
|
||||
MILLISECONDS_PER_DAY,
|
||||
MILLISECONDS_PER_SECOND,
|
||||
} from '#src/common/time';
|
||||
import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
|
||||
import {
|
||||
type TbankBond,
|
||||
type TbankCandle,
|
||||
TbankClient,
|
||||
type TbankCoupon,
|
||||
type TbankDividend,
|
||||
type TbankFundamentals,
|
||||
type TbankLastPrice,
|
||||
type TbankShare,
|
||||
type TbankSyncAttempt,
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { candleGaps, utcDay } from '../domain/candle-coverage';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
type CatalogSource,
|
||||
} from '../infrastructure/catalog-discovery.repository';
|
||||
import { toSavedCandle } from '../infrastructure/tbank-candle.mapper';
|
||||
import { previousClose, timestamp } from './instrument-market';
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
export type CatalogPayload = {
|
||||
bondEvents?: MoexBondEvent[];
|
||||
candle?: null | TbankCandle;
|
||||
coupons?: TbankCoupon[];
|
||||
creditRating?: Awaited<ReturnType<NcrRatingsClient['rating']>>;
|
||||
description?: null | string;
|
||||
dividends?: TbankDividend[];
|
||||
effectiveYield?: Awaited<ReturnType<MoexClient['bondYield']>>;
|
||||
fundamentals?: null | TbankFundamentals;
|
||||
instrument?: null | TbankBond | TbankShare;
|
||||
price?: null | TbankLastPrice;
|
||||
tradingStatus?: null | string;
|
||||
};
|
||||
|
||||
const previousCloseLookbackDays = 20;
|
||||
const previousCloseLookbackMs =
|
||||
previousCloseLookbackDays * MILLISECONDS_PER_DAY;
|
||||
const payoutHistoryYears = 5;
|
||||
const payoutFutureYears = 2;
|
||||
const quotationScale = 9;
|
||||
const quotationUnits = 1_000_000_000n;
|
||||
|
||||
@Injectable()
|
||||
export class CatalogSourceLoader {
|
||||
constructor(
|
||||
private readonly repository: CatalogDiscoveryRepository,
|
||||
private readonly tbank: TbankClient,
|
||||
private readonly moex: MoexClient,
|
||||
private readonly ratings: NcrRatingsClient,
|
||||
private readonly candles: CandleRepository,
|
||||
) {}
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind.
|
||||
async load(
|
||||
input: CatalogSource,
|
||||
token: string,
|
||||
attempt: TbankSyncAttempt,
|
||||
): Promise<CatalogPayload> {
|
||||
if (input.kind === 'detail' || input.kind === 'bond-current') {
|
||||
const instrument =
|
||||
input.instrumentType === 'share'
|
||||
? await this.tbank.getShare(token, input.uid)
|
||||
: await this.tbank.getBond(token, input.uid);
|
||||
if (instrument && instrument.uid !== input.uid) {
|
||||
throw new Error('Catalog UID mismatch');
|
||||
}
|
||||
// eslint-disable-next-line sonarjs/different-types-comparison -- Defensive validation rejects missing upstream payloads instead of caching them as absence.
|
||||
if (instrument === undefined) throw new Error('Missing catalog response');
|
||||
return { instrument: instrument ? publicInstrument({ ...instrument, uid: input.uid }) : null };
|
||||
}
|
||||
if (input.kind === 'trading-status') {
|
||||
return {
|
||||
tradingStatus: await this.tbank.getTradingStatus(token, input.uid),
|
||||
};
|
||||
}
|
||||
if (input.kind === 'comparison') {
|
||||
const prices = await this.repository.readSources([
|
||||
{ ...input, kind: 'last-price', rangeKey: undefined },
|
||||
]);
|
||||
if (!prices.length) throw new Error('Price dependency missing');
|
||||
const price = (prices[0].payload as CatalogPayload).price;
|
||||
const priceAt = timestamp(price?.time);
|
||||
if (!priceAt) return { candle: null };
|
||||
const now = new Date();
|
||||
const range = {
|
||||
from: utcDay(new Date(Date.parse(priceAt) - previousCloseLookbackMs)),
|
||||
to: new Date(priceAt),
|
||||
};
|
||||
const saved = await this.candles.read(input.uid, range);
|
||||
const gaps = candleGaps(range, saved.coverage, now);
|
||||
for (const gap of gaps) {
|
||||
const response = await this.tbank.getCandles(
|
||||
token,
|
||||
input.uid,
|
||||
gap.from,
|
||||
gap.to,
|
||||
);
|
||||
const rows = response.map(toSavedCandle);
|
||||
if (!(await this.candles.stage(attempt, gap, rows, now))) {
|
||||
throw new Error('Cancelled candle comparison');
|
||||
}
|
||||
}
|
||||
if (
|
||||
gaps.length &&
|
||||
!(await this.candles.publish(
|
||||
attempt,
|
||||
{ uid: input.uid, ranges: gaps },
|
||||
false,
|
||||
))
|
||||
) {
|
||||
throw new Error('Cancelled candle comparison');
|
||||
}
|
||||
const values = (await this.candles.read(input.uid, range)).data.map(
|
||||
(row) => {
|
||||
const value = divide(
|
||||
decimalFromString(row.close),
|
||||
decimalFromString('1'),
|
||||
quotationScale,
|
||||
).value;
|
||||
return {
|
||||
time: {
|
||||
seconds: String(Date.parse(row.time) / MILLISECONDS_PER_SECOND),
|
||||
},
|
||||
close: {
|
||||
units: String(value / quotationUnits),
|
||||
nano: Number(value % quotationUnits),
|
||||
},
|
||||
isComplete: row.isComplete,
|
||||
};
|
||||
},
|
||||
);
|
||||
return { candle: previousClose(values, priceAt) };
|
||||
}
|
||||
const details = await this.repository.readSources([
|
||||
{ ...input, kind: 'detail', rangeKey: undefined },
|
||||
...(input.instrumentType === 'bond'
|
||||
? [{ ...input, kind: 'bond-current' as const, rangeKey: undefined }]
|
||||
: []),
|
||||
]);
|
||||
const instrument = details
|
||||
.map((row) => (row.payload as CatalogPayload).instrument)
|
||||
.find(Boolean);
|
||||
if (
|
||||
!instrument &&
|
||||
!['bond-events', 'coupons', 'dividends'].includes(input.kind)
|
||||
) {
|
||||
throw new Error('Instrument dependency missing');
|
||||
}
|
||||
if (input.kind === 'description') {
|
||||
return {
|
||||
description: instrument?.assetUid
|
||||
? await this.tbank.getAssetDescription(token, instrument?.assetUid)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'fundamentals') {
|
||||
return {
|
||||
fundamentals: instrument?.assetUid
|
||||
? await this.tbank.getFundamentals(token, instrument?.assetUid)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'coupons' || input.kind === 'dividends') {
|
||||
const year = Number(input.rangeKey);
|
||||
const [from, to] = input.rangeKey?.startsWith('[')
|
||||
? (JSON.parse(input.rangeKey) as [string, string])
|
||||
: [
|
||||
`${year - payoutHistoryYears}-01-01`,
|
||||
`${year + payoutFutureYears}-01-01`,
|
||||
];
|
||||
return input.kind === 'coupons'
|
||||
? {
|
||||
coupons: await this.tbank.getBondCoupons(
|
||||
token,
|
||||
input.uid,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
}
|
||||
: {
|
||||
dividends: await this.tbank.getDividends(
|
||||
token,
|
||||
input.uid,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (input.kind === 'bond-events') {
|
||||
const ticker = instrument?.ticker || input.ticker;
|
||||
if (!ticker) throw new Error('Bond ticker missing');
|
||||
return { bondEvents: await this.moex.bondEvents(ticker) };
|
||||
}
|
||||
if (input.kind === 'yield') {
|
||||
return {
|
||||
effectiveYield:
|
||||
instrument?.ticker && instrument.isin && instrument.classCode
|
||||
? await this.moex.bondYield(
|
||||
instrument.ticker,
|
||||
instrument.isin,
|
||||
instrument.classCode,
|
||||
)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
if (input.kind === 'rating') {
|
||||
return {
|
||||
creditRating: instrument?.isin
|
||||
? await this.ratings.rating(instrument.isin)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
throw new Error('Unsupported catalog source');
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
import type {
|
||||
TbankCandle,
|
||||
TbankTimestamp,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
|
||||
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
|
||||
|
||||
|
||||
@@ -4,8 +4,9 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../common/decimal';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
} from '#src/common/decimal';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import {
|
||||
PortfolioDto,
|
||||
PortfolioPositionDto,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { publicInstrument } from './public-instrument';
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export function publicInstrument(value: TbankBond | TbankShare) {
|
||||
const bond = value as TbankBond;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
|
||||
import { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
@@ -14,7 +15,8 @@ import {
|
||||
|
||||
type IdentifiedBond = TbankBond & { uid: string };
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
|
||||
import { money } from '../infrastructure/bond-money.mapper';
|
||||
|
||||
const MIN_LOT_EXCLUSIVE = 0;
|
||||
@@ -25,12 +27,10 @@ export class BondCatalogService {
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<BondSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'bond', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
@@ -58,9 +58,8 @@ export class BondCatalogService {
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'bond', uid, observe);
|
||||
const result = await this.discovery.detail(userId, 'bond', uid);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
@@ -72,13 +71,11 @@ export class BondCatalogService {
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<BondResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'bond',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -5,19 +5,20 @@ import {
|
||||
divide,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
type TbankCoupon,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
type CatalogPayload,
|
||||
@@ -54,14 +55,12 @@ export class BondIncomeService {
|
||||
async metrics(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondMetricsResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'metrics',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -82,7 +81,6 @@ export class BondIncomeService {
|
||||
async coupons(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondCouponsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
@@ -93,7 +91,7 @@ export class BondIncomeService {
|
||||
userId,
|
||||
'bond',
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
['detail', 'coupons'],
|
||||
{ from, to },
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
add,
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -38,14 +39,12 @@ export class BondMarketService {
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<BondQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'bond',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -59,12 +58,10 @@ export class BondMarketService {
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: BondCandlesQueryDto['period'] = 'month',
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
{ uid, instrumentType: 'bond', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import type { PortfolioDto } from '../../../portfolio/portfolio.api';
|
||||
import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
import { bondCoupons, bondMetrics } from './bond-income.service';
|
||||
import { bondQuote } from './bond-market.service';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { formatDecimal } from '../../../../../common/decimal';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import { type TbankMoney } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export function money(value?: TbankMoney) {
|
||||
const currency = value?.currency?.toUpperCase();
|
||||
|
||||
@@ -11,9 +11,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BondCatalogService } from '../application/bond-catalog.service';
|
||||
import { BondIncomeService } from '../application/bond-income.service';
|
||||
import { BondMarketService } from '../application/bond-market.service';
|
||||
@@ -47,9 +47,8 @@ export class BondController {
|
||||
metrics(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.metrics(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.metrics(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@@ -61,7 +60,6 @@ export class BondController {
|
||||
return this.bonds.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,12 +68,10 @@ export class BondController {
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: BondTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -84,9 +80,8 @@ export class BondController {
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.bonds.get(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.bonds.get(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/quote')
|
||||
@@ -94,9 +89,8 @@ export class BondController {
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@@ -110,7 +104,6 @@ export class BondController {
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -128,8 +121,7 @@ export class BondController {
|
||||
coupons(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.coupons(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.income.coupons(request.user.sub, uid);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
|
||||
|
||||
import {
|
||||
StockCandlesDto,
|
||||
StockCandlesQueryDto,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
|
||||
const FIRST_RANGE_END_INDEX = 1;
|
||||
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import {
|
||||
claimSyncWork,
|
||||
lockConnectionGeneration,
|
||||
} from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import {
|
||||
isJoinableRunningAttempt,
|
||||
type TbankSyncAttempt,
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
type CandleRange,
|
||||
confirmedCandleCoverage,
|
||||
@@ -62,34 +71,30 @@ export class CandleRepository {
|
||||
priority = 1,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { ...owner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, owner);
|
||||
if (!current) return null;
|
||||
const scope = { ...owner, scopeKey };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
});
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === runId &&
|
||||
active.has(previous.attemptId)
|
||||
) {
|
||||
const previousIntent = previous.intent as Prisma.JsonObject;
|
||||
const joinable = isJoinableRunningAttempt(previous, runId, active)
|
||||
? previous
|
||||
: null;
|
||||
if (joinable) {
|
||||
const previousIntent = joinable.intent as Prisma.JsonObject;
|
||||
const previousNow =
|
||||
typeof previousIntent.now === 'string' ? previousIntent.now : '';
|
||||
const nextPriority = Math.min(previous.priority, priority);
|
||||
const nextPriority = Math.min(joinable.priority, priority);
|
||||
if (
|
||||
nextPriority === previous.priority &&
|
||||
nextPriority === joinable.priority &&
|
||||
(!intent.force ||
|
||||
(previousIntent.force === true &&
|
||||
(!intent.now || intent.now <= previousNow)))
|
||||
) {
|
||||
return { work: previous, launch: false };
|
||||
return { work: joinable, launch: false };
|
||||
}
|
||||
const work = await tx.tbankSyncWork.update({
|
||||
where: { id: previous.id },
|
||||
where: { id: joinable.id },
|
||||
data: {
|
||||
priority: nextPriority,
|
||||
intent: {
|
||||
@@ -115,7 +120,7 @@ export class CandleRepository {
|
||||
processRunId: runId,
|
||||
state: 'running',
|
||||
priority,
|
||||
intent: JSON.parse(JSON.stringify(intent)) as Prisma.InputJsonObject,
|
||||
intent: toJson(intent),
|
||||
progress: {},
|
||||
errorCode: null,
|
||||
};
|
||||
@@ -163,24 +168,17 @@ export class CandleRepository {
|
||||
).values(),
|
||||
];
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const valid = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { progress: { lastWindow: key } },
|
||||
const valid = await claimSyncWork(tx, attempt, {
|
||||
progress: { lastWindow: key },
|
||||
});
|
||||
if (!valid.count) return false;
|
||||
if (!valid) return false;
|
||||
const identity = {
|
||||
workId: attempt.id,
|
||||
attemptId: attempt.attemptId,
|
||||
sourceKey: 'candles',
|
||||
itemKey: key,
|
||||
};
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({ range, candles: rows, fetchedAt }),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson({ range, candles: rows, fetchedAt });
|
||||
await tx.tbankSyncStage.upsert({
|
||||
where: { workId_attemptId_sourceKey_itemKey: identity },
|
||||
create: { ...identity, payload },
|
||||
@@ -196,18 +194,11 @@ export class CandleRepository {
|
||||
completeWork = true,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: {
|
||||
...(completeWork ? { state: 'complete' } : {}),
|
||||
errorCode: null,
|
||||
},
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
...(completeWork ? { state: 'complete' } : {}),
|
||||
errorCode: null,
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
const staged = await tx.tbankSyncStage.findMany({
|
||||
where: {
|
||||
workId: attempt.id,
|
||||
@@ -311,28 +302,17 @@ export class CandleRepository {
|
||||
},
|
||||
});
|
||||
if (!origin) return false;
|
||||
const result = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', errorCode: null },
|
||||
const result = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
errorCode: null,
|
||||
});
|
||||
return result.count > NO_UPDATED_ROWS;
|
||||
return result > NO_UPDATED_ROWS;
|
||||
});
|
||||
}
|
||||
|
||||
fail(attempt: TbankSyncAttempt, errorCode: string) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode },
|
||||
});
|
||||
await claimSyncWork(tx, attempt, { state: 'failed', errorCode });
|
||||
await tx.tbankSyncStage.deleteMany({
|
||||
where: { workId: attempt.id, attemptId: attempt.attemptId },
|
||||
});
|
||||
|
||||
+45
-70
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
const REVISION_INCREMENT = 1;
|
||||
@@ -159,27 +159,19 @@ export class CatalogDiscoveryRepository {
|
||||
initiator: TbankSyncAttempt = attempt,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const origin = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...initiator,
|
||||
state: { in: ['running', 'complete'] },
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { progress: {} },
|
||||
const origin = await claimSyncWork(
|
||||
tx,
|
||||
initiator,
|
||||
{ progress: {} },
|
||||
{ state: { in: ['running', 'complete'] } },
|
||||
);
|
||||
if (!origin) return false;
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!origin.count) return false;
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify(value),
|
||||
) as Prisma.InputJsonObject;
|
||||
if (!claimed) return false;
|
||||
const payload = toJson(value);
|
||||
const identity = { provider, uid: input.uid };
|
||||
await tx.tbankInstrument.upsert({
|
||||
where: { provider_uid: identity },
|
||||
@@ -202,12 +194,10 @@ export class CatalogDiscoveryRepository {
|
||||
const previous = await tx.tbankInstrument.findUniqueOrThrow({
|
||||
where: { provider_uid: identity },
|
||||
});
|
||||
const metadata = JSON.parse(
|
||||
JSON.stringify({
|
||||
...instrument,
|
||||
instrumentType: input.instrumentType,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
const metadata = toJson({
|
||||
...instrument,
|
||||
instrumentType: input.instrumentType,
|
||||
});
|
||||
await tx.tbankInstrument.update({
|
||||
where: { provider_uid: identity },
|
||||
data: {
|
||||
@@ -284,11 +274,8 @@ export class CatalogDiscoveryRepository {
|
||||
active: ReadonlySet<string>,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: { ...owner, invalidToken: false },
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
const current = await lockConnectionGeneration(tx, owner);
|
||||
if (!current) return null;
|
||||
const scope = { ...owner, scopeKey: catalogScope(input) };
|
||||
const previous = await tx.tbankSyncWork.findUnique({
|
||||
where: { userId_revision_scopeKey: scope },
|
||||
@@ -341,34 +328,28 @@ export class CatalogDiscoveryRepository {
|
||||
now: Date,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'complete', progress: {} },
|
||||
const claimed = await claimSyncWork(tx, attempt, {
|
||||
state: 'complete',
|
||||
progress: {},
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
if (!claimed) return false;
|
||||
for (const instrument of instruments) {
|
||||
// Discovery can create a minimal identity, but cannot overwrite verified metadata.
|
||||
const identity = { provider, uid: instrument.uid };
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({
|
||||
uid: instrument.uid,
|
||||
figi: instrument.figi,
|
||||
ticker: instrument.ticker,
|
||||
name: instrument.name,
|
||||
isin: instrument.isin,
|
||||
classCode: instrument.classCode,
|
||||
instrumentKind: instrument.instrumentKind,
|
||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||
currency: instrument.currency,
|
||||
brand: instrument.brand
|
||||
? { logoName: instrument.brand.logoName }
|
||||
: undefined,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson({
|
||||
uid: instrument.uid,
|
||||
figi: instrument.figi,
|
||||
ticker: instrument.ticker,
|
||||
name: instrument.name,
|
||||
isin: instrument.isin,
|
||||
classCode: instrument.classCode,
|
||||
instrumentKind: instrument.instrumentKind,
|
||||
apiTradeAvailableFlag: instrument.apiTradeAvailableFlag,
|
||||
currency: instrument.currency,
|
||||
brand: instrument.brand
|
||||
? { logoName: instrument.brand.logoName }
|
||||
: undefined,
|
||||
});
|
||||
await tx.tbankInstrument.upsert({
|
||||
where: { provider_uid: identity },
|
||||
update: {},
|
||||
@@ -461,6 +442,7 @@ export class CatalogDiscoveryRepository {
|
||||
|
||||
finish(attempt: TbankSyncAttempt, success: boolean) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// Fails sibling attempts whose connection was invalidated mid-flight.
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
@@ -469,16 +451,9 @@ export class CatalogDiscoveryRepository {
|
||||
},
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
await claimSyncWork(tx, attempt, {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import type { SavedCandle } from '../domain/candle-coverage';
|
||||
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { timestamp } from '../application/instrument-market';
|
||||
|
||||
function toSavedCandle(candle: TbankCandle): SavedCandle {
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { MoexModule } from '../../../integrations/moex/moex.module';
|
||||
import { NcrModule } from '../../../integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { MoexModule } from '#src/integrations/moex/moex.module';
|
||||
import { NcrModule } from '#src/integrations/ncr/ncr.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { CandleService } from './application/candle.service';
|
||||
import { CatalogDiscoveryService } from './application/catalog-discovery.service';
|
||||
import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
|
||||
import { CatalogSourceLoader } from './application/catalog-source.loader';
|
||||
import { CandleRepository } from './infrastructure/candle.repository';
|
||||
import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.repository';
|
||||
|
||||
@@ -15,6 +17,7 @@ import { CatalogDiscoveryRepository } from './infrastructure/catalog-discovery.r
|
||||
CandleService,
|
||||
CandleRepository,
|
||||
CatalogEnrichmentService,
|
||||
CatalogSourceLoader,
|
||||
CatalogDiscoveryService,
|
||||
CatalogDiscoveryRepository,
|
||||
],
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { PortfolioModule } from '../portfolio/portfolio.module';
|
||||
import { BondCatalogService } from './bonds/application/bond-catalog.service';
|
||||
|
||||
+1
-3
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import {
|
||||
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
|
||||
async execute(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockCompanyResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'share',
|
||||
uid,
|
||||
'company',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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 { timestamp } from '../../application/instrument-market';
|
||||
import {
|
||||
@@ -22,12 +23,10 @@ export class StockCatalogService {
|
||||
async search(
|
||||
userId: string,
|
||||
query: string,
|
||||
observe = false,
|
||||
): Promise<StockSearchResourceDto> {
|
||||
const result = await this.discovery.search(
|
||||
userId,
|
||||
{ instrumentType: 'share', query },
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
@@ -59,9 +58,8 @@ export class StockCatalogService {
|
||||
async get(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.detail(userId, 'share', uid, observe);
|
||||
const result = await this.discovery.detail(userId, 'share', uid);
|
||||
return {
|
||||
...result,
|
||||
data: result.data
|
||||
@@ -73,13 +71,11 @@ export class StockCatalogService {
|
||||
async getByTicker(
|
||||
userId: string,
|
||||
ticker: string,
|
||||
observe = false,
|
||||
): Promise<StockResourceDto> {
|
||||
const result = await this.discovery.byTicker(
|
||||
userId,
|
||||
'share',
|
||||
ticker,
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
...result,
|
||||
|
||||
@@ -4,18 +4,19 @@ import {
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankDividend,
|
||||
tbankSourceNeedsRefresh,
|
||||
} from '../../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import {
|
||||
ForecastService,
|
||||
PortfolioService,
|
||||
} from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
|
||||
import { moscowDay, timestamp } from '../../application/instrument-market';
|
||||
import { instrumentPosition } from '../../application/instrument-position';
|
||||
@@ -51,7 +52,6 @@ export class StockIncomeService {
|
||||
async dividends(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockDividendsResourceDto> {
|
||||
const connection = await this.access.local(userId);
|
||||
const today = moscowDay(new Date().toISOString());
|
||||
@@ -62,7 +62,7 @@ export class StockIncomeService {
|
||||
userId,
|
||||
'share',
|
||||
[uid],
|
||||
observe,
|
||||
false,
|
||||
['detail', 'dividends'],
|
||||
{ from, to },
|
||||
);
|
||||
|
||||
@@ -6,9 +6,10 @@ import {
|
||||
formatDecimal,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { decimal } from '../../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
|
||||
import { CandleService } from '../../application/candle.service';
|
||||
import {
|
||||
CatalogEnrichmentService,
|
||||
@@ -34,14 +35,12 @@ export class StockMarketService {
|
||||
async quote(
|
||||
userId: string,
|
||||
uid: string,
|
||||
observe = false,
|
||||
): Promise<StockQuoteResourceDto> {
|
||||
const result = await this.sources.section(
|
||||
userId,
|
||||
'share',
|
||||
uid,
|
||||
'quote',
|
||||
observe,
|
||||
);
|
||||
return {
|
||||
data: result.values.instrument
|
||||
@@ -55,12 +54,10 @@ export class StockMarketService {
|
||||
userId: string,
|
||||
uid: string,
|
||||
period: StockCandlesQueryDto['period'] = 'month',
|
||||
observe = false,
|
||||
) {
|
||||
return this.history.read(
|
||||
userId,
|
||||
{ uid, instrumentType: 'share', period },
|
||||
observe,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({
|
||||
jest.mock('#src/integrations/tbank/tbank.api', () => ({
|
||||
TbankAccessService: class {},
|
||||
}));
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@@ -6,11 +6,12 @@ import {
|
||||
} from '@nestjs/common';
|
||||
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 { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
|
||||
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
|
||||
import {
|
||||
selectStockScreenerInstruments,
|
||||
type StockScreenerConditions,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { previousClose } from '../../application/instrument-market';
|
||||
import { metrics } from './get-stock-company.use-case';
|
||||
import { stockDividends } from './stock-income.service';
|
||||
import { stockQuote } from './stock-market.service';
|
||||
jest.mock('../../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankShare } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
export type StockScreenerCatalog = {
|
||||
asOf: Date;
|
||||
|
||||
@@ -17,9 +17,9 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api';
|
||||
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
|
||||
import { StockCatalogService } from '../application/stock-catalog.service';
|
||||
import { StockIncomeService } from '../application/stock-income.service';
|
||||
@@ -64,7 +64,6 @@ export class StockController {
|
||||
return this.stocks.search(
|
||||
request.user.sub,
|
||||
query.query,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -73,12 +72,10 @@ export class StockController {
|
||||
getByTicker(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param() params: StockTickerParamsDto,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.stocks.getByTicker(
|
||||
request.user.sub,
|
||||
params.ticker,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -115,9 +112,8 @@ export class StockController {
|
||||
get(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@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')
|
||||
@@ -125,9 +121,8 @@ export class StockController {
|
||||
quote(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.market.quote(request.user.sub, uid, query.sync === 'observe');
|
||||
return this.market.quote(request.user.sub, uid);
|
||||
}
|
||||
|
||||
@Get(':uid/candles')
|
||||
@@ -141,7 +136,6 @@ export class StockController {
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.period,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -159,12 +153,10 @@ export class StockController {
|
||||
company(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.companyInfo.execute(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,12 +165,10 @@ export class StockController {
|
||||
dividends(
|
||||
@Req() request: Request & { user: AccessPayload },
|
||||
@Param('uid', new ParseUUIDPipe()) uid: string,
|
||||
@Query() query: InvestmentReadQueryDto,
|
||||
) {
|
||||
return this.income.dividends(
|
||||
request.user.sub,
|
||||
uid,
|
||||
query.sync === 'observe',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,16 +11,15 @@ import {
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
import {
|
||||
MAX_INSTRUMENT_QUERY_LENGTH,
|
||||
MIN_INSTRUMENT_QUERY_LENGTH,
|
||||
} from '../../../instrument-query';
|
||||
} from '#src/modules/investments/instrument-query';
|
||||
import {
|
||||
InvestmentReadQueryDto,
|
||||
InvestmentResourceMetaDto,
|
||||
} from '../../../investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api';
|
||||
} from '#src/modules/investments/investment-resource.dto';
|
||||
import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
|
||||
|
||||
const MIN_TICKER_LENGTH = 1;
|
||||
const MAX_SCREENER_QUERY_LENGTH = 80;
|
||||
@@ -104,7 +103,7 @@ export class StockScreenerPageDto {
|
||||
@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 })
|
||||
@Transform(({ value }: { value: unknown }) =>
|
||||
typeof value === 'string' ? value.trim() : value,
|
||||
@@ -126,7 +125,7 @@ export class StockTickerParamsDto {
|
||||
@Matches(/^[A-Z0-9][\w.@+-]*$/i)
|
||||
declare ticker: string;
|
||||
}
|
||||
export class StockCandlesQueryDto extends InvestmentReadQueryDto {
|
||||
export class StockCandlesQueryDto {
|
||||
@ApiPropertyOptional({
|
||||
enum: ['month', 'halfYear', 'year', 'all'],
|
||||
default: 'month',
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsIn, IsOptional } from 'class-validator';
|
||||
|
||||
export const availabilityValues = [
|
||||
'available',
|
||||
@@ -17,13 +16,6 @@ export const attemptStateValues = [
|
||||
'failed',
|
||||
] as const;
|
||||
|
||||
export class InvestmentReadQueryDto {
|
||||
@ApiPropertyOptional({ enum: ['observe'] })
|
||||
@IsOptional()
|
||||
@IsIn(['observe'])
|
||||
declare sync?: 'observe';
|
||||
}
|
||||
|
||||
export class InvestmentAttemptProblemDto {
|
||||
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
|
||||
declare section: 'events' | 'membership' | 'operations' | 'portfolio';
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { TbankAccessService, TbankClient } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
@@ -8,54 +7,51 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankOperationItem,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { accountHistory, ledgerRange } from '../../investment-sync-plan';
|
||||
import { ledgerRange } from '../../investment-sync-plan';
|
||||
import {
|
||||
hourlyAttempt,
|
||||
TbankSyncRepository,
|
||||
} from '../../tbank-sync/tbank-sync.api';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import {
|
||||
coverageAsOf,
|
||||
deriveReadCoverage,
|
||||
historyState,
|
||||
serializeRange,
|
||||
} from '../domain/operations-coverage';
|
||||
import {
|
||||
confirmedOperationRanges,
|
||||
operationIntervalsOverlap,
|
||||
operationsSyncPriority,
|
||||
planOperationRead,
|
||||
uncoveredOperationIntervals,
|
||||
} from '../domain/operations-sync-plan';
|
||||
import { mapBrokerageOperation } from '../infrastructure/brokerage-operation.mapper';
|
||||
import { BrokerageOperationsRepository } from '../infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsReadRepository } from '../infrastructure/brokerage-operations-read.repository';
|
||||
import {
|
||||
BrokerageOperationsQueryDto,
|
||||
DEFAULT_OPERATIONS_PAGE_SIZE,
|
||||
} from '../presentation/brokerage-operation.dto';
|
||||
import { BrokerageOperationsSyncService } from './brokerage-operations-sync.service';
|
||||
import { LocalOperationsCursorCodec } from './local-operations-cursor.codec';
|
||||
|
||||
const FIRST_RESULT_OFFSET = 0;
|
||||
const MISSING_MEMBERSHIP_REVISION = 0;
|
||||
const STALE_FRESHNESS_MS = 0;
|
||||
const UNKNOWN_AS_OF_MS = 0;
|
||||
const NO_COVERAGE_ROWS = 0;
|
||||
const LAST_ROW_INDEX = -1;
|
||||
const UNIX_EPOCH_MS = 0;
|
||||
|
||||
type LocalOperationsCursor = {
|
||||
accountId: string;
|
||||
at: string;
|
||||
binding: string;
|
||||
id: string;
|
||||
movingEnd?: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsService {
|
||||
constructor(
|
||||
private readonly access: TbankAccessService,
|
||||
private readonly metadata: InstrumentMetadataService,
|
||||
private readonly repository: BrokerageOperationsRepository,
|
||||
private readonly repository: BrokerageOperationsReadRepository,
|
||||
private readonly sync: BrokerageOperationsSyncService,
|
||||
private readonly config: ConfigService,
|
||||
private readonly cursorCodec: LocalOperationsCursorCodec,
|
||||
private readonly hourlySync: TbankSyncRepository,
|
||||
) {}
|
||||
|
||||
@@ -65,7 +61,7 @@ export class BrokerageOperationsService {
|
||||
query: BrokerageOperationsQueryDto,
|
||||
) {
|
||||
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(
|
||||
(account) => accountId === 'all' || account.accountId === accountId,
|
||||
);
|
||||
@@ -124,64 +120,18 @@ export class BrokerageOperationsService {
|
||||
const saved = await this.repository.readPage(readInput);
|
||||
const hourly = await this.hourlySync.hourly(userId);
|
||||
const accounts = saved?.connection.accounts ?? [];
|
||||
const targets = accounts.flatMap((account) =>
|
||||
from < to
|
||||
? [
|
||||
{
|
||||
accountId: account.accountId,
|
||||
from,
|
||||
to,
|
||||
instrumentUid: query.instrumentUid,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const plan = planOperationRead(targets, saved?.coverage ?? [], now, {
|
||||
const view = deriveReadCoverage({
|
||||
accounts,
|
||||
connectionMembershipAt: saved?.connection.membershipAt ?? null,
|
||||
coverage: saved?.coverage ?? [],
|
||||
from,
|
||||
instrumentUid: query.instrumentUid,
|
||||
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,
|
||||
{ movingEnd: true },
|
||||
);
|
||||
const knownBoundary = accounts.every(
|
||||
(account) => account.openedOn !== null,
|
||||
);
|
||||
to,
|
||||
work: saved?.work ?? [],
|
||||
});
|
||||
const { targets, plan, confirmed, missing, available, complete, lastSuccessfulAt, history, knownBoundary, work } = view;
|
||||
const rows = saved?.rows.slice(FIRST_RESULT_OFFSET, limit) ?? [];
|
||||
const instruments = await this.metadata.savedMany(
|
||||
rows.map((row) => row.payload as TbankOperationItem),
|
||||
@@ -224,49 +174,17 @@ export class BrokerageOperationsService {
|
||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||
dependencies: [membershipMeta, ...metadataDependencies],
|
||||
coverage: {
|
||||
requested: targets.map(serialize),
|
||||
completeRanges: confirmed.map(serialize),
|
||||
missingRanges: missing.map(serialize),
|
||||
asOf:
|
||||
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,
|
||||
requested: targets.map(serializeRange),
|
||||
completeRanges: confirmed.map(serializeRange),
|
||||
missingRanges: missing.map(serializeRange),
|
||||
asOf: coverageAsOf(plan, accounts.map((account) => account.accountId)),
|
||||
historyBoundary: knownBoundary ? 'known' : 'unknown',
|
||||
historyState: saved?.work.some(
|
||||
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
||||
)
|
||||
? 'loading'
|
||||
: saved?.work.some(
|
||||
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
||||
)
|
||||
? 'failed'
|
||||
: knownBoundary &&
|
||||
history.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
)
|
||||
? 'complete'
|
||||
: saved?.coverage.length
|
||||
? 'partial'
|
||||
: 'missing',
|
||||
historyState: historyState(
|
||||
knownBoundary,
|
||||
history,
|
||||
saved?.work ?? [],
|
||||
saved?.coverage.length ?? NO_COVERAGE_ROWS,
|
||||
),
|
||||
},
|
||||
});
|
||||
const last = rows.at(LAST_ROW_INDEX);
|
||||
@@ -301,7 +219,7 @@ export class BrokerageOperationsService {
|
||||
hasNext,
|
||||
nextCursor:
|
||||
hasNext && last && saved
|
||||
? this.encodeCursor({
|
||||
? this.cursorCodec.encode({
|
||||
binding: saved.binding,
|
||||
movingEnd,
|
||||
to: to.toISOString(),
|
||||
@@ -316,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(
|
||||
userId: string,
|
||||
accountId: string,
|
||||
ranges: (openedAt: Date | undefined) => DateOnlyRange[],
|
||||
observe = false,
|
||||
) {
|
||||
const connection = await this.access.local(userId, accountId);
|
||||
const input = {
|
||||
@@ -378,21 +251,19 @@ export class BrokerageOperationsService {
|
||||
periods: ranges,
|
||||
};
|
||||
let saved = await this.repository.readPage(input);
|
||||
if (!observe) {
|
||||
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||
(saved?.periods ?? []).map((period) => ({
|
||||
accountId: account.accountId,
|
||||
...period,
|
||||
})),
|
||||
);
|
||||
if (targets.length) {
|
||||
await this.sync.readPlan(userId, targets, {
|
||||
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
||||
revision: connection.revision,
|
||||
});
|
||||
}
|
||||
saved = await this.repository.readPage(input);
|
||||
const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
|
||||
(saved?.periods ?? []).map((period) => ({
|
||||
accountId: account.accountId,
|
||||
...period,
|
||||
})),
|
||||
);
|
||||
if (targets.length) {
|
||||
await this.sync.readPlan(userId, targets, {
|
||||
movingEnd: saved?.periods?.some((period) => period.movingEnd),
|
||||
revision: connection.revision,
|
||||
});
|
||||
}
|
||||
saved = await this.repository.readPage(input);
|
||||
const now = new Date();
|
||||
const hourly = await this.hourlySync.hourly(userId);
|
||||
const membership = investmentResourceMeta({
|
||||
@@ -466,12 +337,12 @@ export class BrokerageOperationsService {
|
||||
lastAttempt: hourlyAttempt(hourly, 'operations', accountId === 'all' ? undefined : accountId),
|
||||
dependencies: [membership],
|
||||
coverage: {
|
||||
requested: targets.map(serialize),
|
||||
completeRanges: confirmed.map(serialize),
|
||||
requested: targets.map(serializeRange),
|
||||
completeRanges: confirmed.map(serializeRange),
|
||||
missingRanges: uncoveredOperationIntervals(
|
||||
plan.intervals,
|
||||
confirmed,
|
||||
).map(serialize),
|
||||
).map(serializeRange),
|
||||
asOf:
|
||||
complete && targets.length
|
||||
? new Date(
|
||||
@@ -541,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() };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { decode, encode } from './local-operations-cursor.codec';
|
||||
|
||||
const secret = 'secret';
|
||||
const cursor = {
|
||||
accountId: 'account',
|
||||
at: '2026-01-01T00:00:00.000Z',
|
||||
binding: 'owner-filter-binding',
|
||||
id: 'operation',
|
||||
movingEnd: false,
|
||||
to: '2026-02-01T00:00:00.000Z',
|
||||
};
|
||||
|
||||
describe('local operations cursor', () => {
|
||||
it('roundtrips the opaque cursor, including its owner and filter binding', () => {
|
||||
expect(decode(encode(cursor, secret), secret)).toEqual(cursor);
|
||||
});
|
||||
|
||||
it('rejects a tampered cursor and a cursor signed with a different key', () => {
|
||||
const encoded = encode(cursor, secret);
|
||||
expect(() => decode(`${encoded}x`, secret)).toThrow();
|
||||
expect(() => decode(encoded, 'different-secret')).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
|
||||
export type LocalOperationsCursor = {
|
||||
accountId: string;
|
||||
at: string;
|
||||
binding: string;
|
||||
id: string;
|
||||
movingEnd?: boolean;
|
||||
to: string;
|
||||
};
|
||||
|
||||
// Opaque cursor: base64url body + HMAC-SHA256 signature over JWT_SECRET.
|
||||
// Wire format and validation are part of the HTTP contract and must not change.
|
||||
export function encode(value: LocalOperationsCursor, secret: string): string {
|
||||
const body = Buffer.from(JSON.stringify(value)).toString('base64url');
|
||||
return `${body}.${createHmac('sha256', secret).update(body).digest('base64url')}`;
|
||||
}
|
||||
|
||||
export function decode(value: string, secret: string): LocalOperationsCursor {
|
||||
try {
|
||||
const [body, signature, extra] = value.split('.');
|
||||
const expected = createHmac('sha256', 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class LocalOperationsCursorCodec {
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
encode(value: LocalOperationsCursor): string {
|
||||
return encode(value, this.secret());
|
||||
}
|
||||
|
||||
decode(value: string): LocalOperationsCursor {
|
||||
return decode(value, this.secret());
|
||||
}
|
||||
|
||||
private secret(): string {
|
||||
return this.config.getOrThrow<string>('JWT_SECRET');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import { accountHistory } from '../../investment-sync-plan';
|
||||
import { confirmedOperationRanges, type OperationCoverage,
|
||||
type OperationInterval,
|
||||
operationIntervalsOverlap,
|
||||
operationsSyncPriority,
|
||||
planOperationRead,
|
||||
uncoveredOperationIntervals } from './operations-sync-plan';
|
||||
|
||||
const UNKNOWN_AS_OF_MS = 0;
|
||||
|
||||
type WorkState = {
|
||||
errorCode: null | string;
|
||||
id: string;
|
||||
intervals: OperationInterval[];
|
||||
priority: number;
|
||||
state: string;
|
||||
};
|
||||
|
||||
type CoverageAccount = { accountId: string; openedOn: null | string };
|
||||
|
||||
// Pure derivation of the ledger read view: what was requested, what is
|
||||
// confirmed, what is missing, and which sync work is currently relevant.
|
||||
export function deriveReadCoverage(input: {
|
||||
accounts: CoverageAccount[];
|
||||
connectionMembershipAt: Date | null;
|
||||
coverage: OperationCoverage[];
|
||||
from: Date;
|
||||
instrumentUid?: string;
|
||||
movingEnd: boolean;
|
||||
now: Date;
|
||||
to: Date;
|
||||
work: WorkState[];
|
||||
}) {
|
||||
const targets = input.accounts.flatMap((account) =>
|
||||
input.from < input.to
|
||||
? [
|
||||
{
|
||||
accountId: account.accountId,
|
||||
from: input.from,
|
||||
to: input.to,
|
||||
instrumentUid: input.instrumentUid,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const plan = planOperationRead(targets, input.coverage, input.now, {
|
||||
movingEnd: input.movingEnd,
|
||||
});
|
||||
const confirmed = confirmedOperationRanges(plan.intervals, input.coverage);
|
||||
const missing = uncoveredOperationIntervals(
|
||||
plan.intervals,
|
||||
confirmed.map((range) => ({
|
||||
...range,
|
||||
instrumentUid: input.instrumentUid,
|
||||
})),
|
||||
);
|
||||
const available = Boolean(input.connectionMembershipAt);
|
||||
const complete = available && !missing.length;
|
||||
const relevant = input.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 = input.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 ? input.connectionMembershipAt : null) ??
|
||||
null;
|
||||
const history = planOperationRead(
|
||||
accountHistory(input.accounts, input.now),
|
||||
input.coverage,
|
||||
input.now,
|
||||
{ movingEnd: true },
|
||||
);
|
||||
const knownBoundary = input.accounts.every(
|
||||
(account) => account.openedOn !== null,
|
||||
);
|
||||
return {
|
||||
targets,
|
||||
plan,
|
||||
confirmed,
|
||||
missing,
|
||||
available,
|
||||
complete,
|
||||
work,
|
||||
lastSuccessfulAt,
|
||||
history,
|
||||
knownBoundary,
|
||||
};
|
||||
}
|
||||
|
||||
export function coverageAsOf(
|
||||
plan: ReturnType<typeof planOperationRead>,
|
||||
accountIds: string[],
|
||||
): null | string {
|
||||
if (
|
||||
accountIds.length &&
|
||||
accountIds.every((accountId) =>
|
||||
plan.intervals.some(
|
||||
(interval) =>
|
||||
interval.accountId === accountId && interval.asOf,
|
||||
),
|
||||
)
|
||||
) {
|
||||
return new Date(
|
||||
Math.min(
|
||||
...accountIds.map((accountId) =>
|
||||
Math.max(
|
||||
...plan.intervals
|
||||
.filter((interval) => interval.accountId === accountId)
|
||||
.map((interval) => interval.asOf?.valueOf() ?? UNKNOWN_AS_OF_MS),
|
||||
),
|
||||
),
|
||||
),
|
||||
).toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function historyState(
|
||||
knownBoundary: boolean,
|
||||
history: ReturnType<typeof planOperationRead>,
|
||||
work: WorkState[],
|
||||
coverageCount: number,
|
||||
): 'complete' | 'failed' | 'loading' | 'missing' | 'partial' {
|
||||
if (
|
||||
work.some(
|
||||
(work) => work.state === 'running' && work.priority > operationsSyncPriority.screen,
|
||||
)
|
||||
) {
|
||||
return 'loading';
|
||||
}
|
||||
if (
|
||||
work.some(
|
||||
(work) => work.state === 'failed' && work.priority > operationsSyncPriority.screen,
|
||||
)
|
||||
) {
|
||||
return 'failed';
|
||||
}
|
||||
if (
|
||||
knownBoundary &&
|
||||
history.intervals.every(
|
||||
(interval) => interval.availability === 'complete',
|
||||
)
|
||||
) {
|
||||
return 'complete';
|
||||
}
|
||||
return coverageCount ? 'partial' : 'missing';
|
||||
}
|
||||
|
||||
export function serializeRange(range: { accountId: string; from: Date; to: Date }) {
|
||||
return { accountId: range.accountId, from: range.from.toISOString(), to: range.to.toISOString() };
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { type OperationInterval,
|
||||
operationIntervalsOverlap,
|
||||
operationsSyncPriority } from './operations-sync-plan';
|
||||
|
||||
export type OperationsIntent = {
|
||||
atomic: boolean;
|
||||
atomicIntervals?: string[];
|
||||
intervals: string[];
|
||||
screenIntervals?: string[];
|
||||
};
|
||||
|
||||
export function intervalKey(interval: OperationInterval): string {
|
||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||
if (!(interval.from < interval.to)) {
|
||||
throw new Error('Invalid operations interval');
|
||||
}
|
||||
return JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.from.toISOString(),
|
||||
interval.to.toISOString(),
|
||||
interval.instrumentUid ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
export function decodeInterval(key: string): OperationInterval {
|
||||
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
|
||||
return {
|
||||
accountId,
|
||||
from: new Date(from),
|
||||
to: new Date(to),
|
||||
...(instrumentUid ? { instrumentUid } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function extendRunningIntent(input: {
|
||||
additions: OperationInterval[];
|
||||
intervals: OperationInterval[];
|
||||
pending: OperationInterval[];
|
||||
previous: OperationsIntent | undefined;
|
||||
previousPriority: number;
|
||||
priority: number;
|
||||
publicationAtomic: boolean;
|
||||
}): OperationsIntent {
|
||||
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||
const overlapsRequested = (item: OperationInterval) =>
|
||||
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||
const retainedScreen = previous?.screenIntervals ??
|
||||
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||
const addedScreen = priority === operationsSyncPriority.screen
|
||||
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||
: [];
|
||||
const ordered = priority < previousPriority
|
||||
? [
|
||||
...pending.filter(overlapsRequested),
|
||||
...additions,
|
||||
...pending.filter((item) => !overlapsRequested(item)),
|
||||
]
|
||||
: [...pending, ...additions];
|
||||
return {
|
||||
atomic: previous?.atomic ?? publicationAtomic,
|
||||
atomicIntervals: previous?.atomic
|
||||
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||
: [],
|
||||
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||
intervals: ordered.map(intervalKey),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { formatDecimal } from '../../../../common/decimal';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||
import { formatDecimal } from '#src/common/decimal';
|
||||
import { MILLISECONDS_PER_SECOND } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { TbankMoney, TbankOperationItem } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { type OperationCategory } from '../../operation-category';
|
||||
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
|
||||
|
||||
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
import {
|
||||
dateOnlyBounds,
|
||||
DateOnlyRange,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import { decodeInterval } from '../domain/operations-interval-codec';
|
||||
|
||||
const CURSOR_LOOKAHEAD_ROWS = 1;
|
||||
|
||||
@Injectable()
|
||||
export class BrokerageOperationsReadRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async readPage(input: {
|
||||
accountId: string;
|
||||
categories: string[];
|
||||
cursor?: { accountId: string; at: string; binding: string; id: string };
|
||||
filterKey: string;
|
||||
from: Date;
|
||||
instrumentUid?: string;
|
||||
limit?: number;
|
||||
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||
revision: string;
|
||||
to: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const connection = await tx.tbankConnection.findUnique({
|
||||
where: {
|
||||
userId_revision: { userId: input.userId, revision: input.revision },
|
||||
invalidToken: false,
|
||||
},
|
||||
select: {
|
||||
membershipAt: true,
|
||||
membershipRevision: true,
|
||||
accounts: {
|
||||
where:
|
||||
input.accountId === 'all' ? {} : { accountId: input.accountId },
|
||||
orderBy: { accountId: 'asc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
name: true,
|
||||
openedOn: true,
|
||||
operationsRevision: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!connection) return null;
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
input.accountId !== 'all' &&
|
||||
!connection.accounts.length
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const accounts = connection.accounts;
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||
const openedAt =
|
||||
oldest && accounts.every((account) => account.openedOn)
|
||||
? new Date(`${oldest}T00:00:00+03:00`)
|
||||
: undefined;
|
||||
const periods = input.periods?.(openedAt).map((period) => {
|
||||
const now = new Date();
|
||||
const bounds = dateOnlyBounds(period, now);
|
||||
return {
|
||||
...bounds,
|
||||
key: `${period.from}/${period.to}`,
|
||||
movingEnd: period.to >= moscowDate(now),
|
||||
};
|
||||
});
|
||||
const binding = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
input.userId,
|
||||
input.revision,
|
||||
input.accountId === 'all' ? connection.membershipRevision : null,
|
||||
accounts.map((account) => [
|
||||
account.accountId,
|
||||
account.operationsRevision,
|
||||
]),
|
||||
input.filterKey,
|
||||
]),
|
||||
)
|
||||
.digest('base64url');
|
||||
if (input.cursor && input.cursor.binding !== binding) {
|
||||
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||
}
|
||||
const scope = {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
accountId: { in: accounts.map((account) => account.accountId) },
|
||||
};
|
||||
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||
where: scope,
|
||||
});
|
||||
const work = await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: { startsWith: 'operations:' },
|
||||
state: { in: ['running', 'failed'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
errorCode: true,
|
||||
intent: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
const membershipWork = await tx.tbankSyncWork.findUnique({
|
||||
where: {
|
||||
userId_revision_scopeKey: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: 'membership',
|
||||
},
|
||||
},
|
||||
select: { id: true, state: true, errorCode: true },
|
||||
});
|
||||
const after = input.cursor;
|
||||
const rows = await tx.tbankOperation.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
...(periods
|
||||
? {
|
||||
OR: periods.map((period) => ({
|
||||
occurredAt: { gte: period.from, lt: period.to },
|
||||
})),
|
||||
}
|
||||
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid }
|
||||
: {}),
|
||||
...(input.categories.length
|
||||
? { category: { in: input.categories } }
|
||||
: {}),
|
||||
...(after
|
||||
? {
|
||||
OR: [
|
||||
{ occurredAt: { lt: new Date(after.at) } },
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: { gt: after.accountId },
|
||||
},
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: after.accountId,
|
||||
brokerOperationId: { gt: after.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ occurredAt: 'desc' },
|
||||
{ accountId: 'asc' },
|
||||
{ brokerOperationId: 'asc' },
|
||||
],
|
||||
...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }),
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
periods,
|
||||
membershipWork,
|
||||
binding,
|
||||
rows,
|
||||
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
|
||||
...row,
|
||||
instrumentUid: instrumentPredicate || undefined,
|
||||
})),
|
||||
work: work.map(({ intent, ...row }) => ({
|
||||
...row,
|
||||
intervals: (intent as { intervals: string[] }).intervals.map(
|
||||
decodeInterval,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
+25
-276
@@ -1,26 +1,25 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { createHash, randomUUID } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type {
|
||||
TbankOperationItem,
|
||||
TbankOperationsByCursorResponse,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { isTbankProtocolValue, TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import {
|
||||
dateOnlyBounds,
|
||||
DateOnlyRange,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
isJoinableRunningAttempt,
|
||||
type TbankOperationItem, type TbankOperationsByCursorResponse,
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
import {
|
||||
decodeInterval,
|
||||
extendRunningIntent,
|
||||
intervalKey,
|
||||
type OperationsIntent,
|
||||
} from '../domain/operations-interval-codec';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
@@ -33,13 +32,13 @@ import {
|
||||
operationTimestamp,
|
||||
} from './brokerage-operation.mapper';
|
||||
|
||||
const CURSOR_LOOKAHEAD_ROWS = 1;
|
||||
const EMPTY_INTERVAL_COUNT = 0;
|
||||
const SINGLE_WORK_GROUP = 1;
|
||||
const INITIAL_PAGE_COUNT = 0;
|
||||
const PAGE_INCREMENT = 1;
|
||||
const EXPECTED_UPDATED_WORK_ITEMS = 1;
|
||||
|
||||
export type { OperationsIntent } from '../domain/operations-interval-codec';
|
||||
export type { OperationInterval } from '../domain/operations-sync-plan';
|
||||
|
||||
export type OperationAttempt = {
|
||||
@@ -48,12 +47,6 @@ export type OperationAttempt = {
|
||||
revision: string;
|
||||
userId: string;
|
||||
};
|
||||
type OperationsIntent = {
|
||||
atomic: boolean;
|
||||
atomicIntervals?: string[];
|
||||
intervals: string[];
|
||||
screenIntervals?: string[];
|
||||
};
|
||||
type PageProgress = {
|
||||
complete: boolean;
|
||||
cursor: string;
|
||||
@@ -66,176 +59,6 @@ const progressItem = '';
|
||||
export class BrokerageOperationsRepository {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
async readPage(input: {
|
||||
accountId: string;
|
||||
categories: string[];
|
||||
cursor?: { accountId: string; at: string; binding: string; id: string };
|
||||
filterKey: string;
|
||||
from: Date;
|
||||
instrumentUid?: string;
|
||||
limit?: number;
|
||||
periods?: (openedAt: Date | undefined) => DateOnlyRange[];
|
||||
revision: string;
|
||||
to: Date;
|
||||
userId: string;
|
||||
}) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const connection = await tx.tbankConnection.findUnique({
|
||||
where: {
|
||||
userId_revision: { userId: input.userId, revision: input.revision },
|
||||
invalidToken: false,
|
||||
},
|
||||
select: {
|
||||
membershipAt: true,
|
||||
membershipRevision: true,
|
||||
accounts: {
|
||||
where:
|
||||
input.accountId === 'all' ? {} : { accountId: input.accountId },
|
||||
orderBy: { accountId: 'asc' },
|
||||
select: {
|
||||
accountId: true,
|
||||
name: true,
|
||||
openedOn: true,
|
||||
operationsRevision: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!connection) return null;
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
input.accountId !== 'all' &&
|
||||
!connection.accounts.length
|
||||
) {
|
||||
throw new NotFoundException();
|
||||
}
|
||||
const accounts = connection.accounts;
|
||||
// eslint-disable-next-line sonarjs/no-alphabetical-sort -- Code-unit ordering is intentional for canonical keys and ISO dates; locale collation would change persisted bindings.
|
||||
const oldest = accounts.map((account) => account.openedOn).sort()[0];
|
||||
const openedAt =
|
||||
oldest && accounts.every((account) => account.openedOn)
|
||||
? new Date(`${oldest}T00:00:00+03:00`)
|
||||
: undefined;
|
||||
const periods = input.periods?.(openedAt).map((period) => {
|
||||
const now = new Date();
|
||||
const bounds = dateOnlyBounds(period, now);
|
||||
return {
|
||||
...bounds,
|
||||
key: `${period.from}/${period.to}`,
|
||||
movingEnd: period.to >= moscowDate(now),
|
||||
};
|
||||
});
|
||||
const binding = createHash('sha256')
|
||||
.update(
|
||||
JSON.stringify([
|
||||
input.userId,
|
||||
input.revision,
|
||||
input.accountId === 'all' ? connection.membershipRevision : null,
|
||||
accounts.map((account) => [
|
||||
account.accountId,
|
||||
account.operationsRevision,
|
||||
]),
|
||||
input.filterKey,
|
||||
]),
|
||||
)
|
||||
.digest('base64url');
|
||||
if (input.cursor && input.cursor.binding !== binding) {
|
||||
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||
}
|
||||
const scope = {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
accountId: { in: accounts.map((account) => account.accountId) },
|
||||
};
|
||||
const coverage = await tx.tbankOperationCoverage.findMany({
|
||||
where: scope,
|
||||
});
|
||||
const work = await tx.tbankSyncWork.findMany({
|
||||
where: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: { startsWith: 'operations:' },
|
||||
state: { in: ['running', 'failed'] },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
state: true,
|
||||
errorCode: true,
|
||||
intent: true,
|
||||
priority: true,
|
||||
},
|
||||
});
|
||||
const membershipWork = await tx.tbankSyncWork.findUnique({
|
||||
where: {
|
||||
userId_revision_scopeKey: {
|
||||
userId: input.userId,
|
||||
revision: input.revision,
|
||||
scopeKey: 'membership',
|
||||
},
|
||||
},
|
||||
select: { id: true, state: true, errorCode: true },
|
||||
});
|
||||
const after = input.cursor;
|
||||
const rows = await tx.tbankOperation.findMany({
|
||||
where: {
|
||||
...scope,
|
||||
...(periods
|
||||
? {
|
||||
OR: periods.map((period) => ({
|
||||
occurredAt: { gte: period.from, lt: period.to },
|
||||
})),
|
||||
}
|
||||
: { occurredAt: { gte: input.from, lt: input.to } }),
|
||||
...(input.instrumentUid
|
||||
? { instrumentUid: input.instrumentUid }
|
||||
: {}),
|
||||
...(input.categories.length
|
||||
? { category: { in: input.categories } }
|
||||
: {}),
|
||||
...(after
|
||||
? {
|
||||
OR: [
|
||||
{ occurredAt: { lt: new Date(after.at) } },
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: { gt: after.accountId },
|
||||
},
|
||||
{
|
||||
occurredAt: new Date(after.at),
|
||||
accountId: after.accountId,
|
||||
brokerOperationId: { gt: after.id },
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
},
|
||||
orderBy: [
|
||||
{ occurredAt: 'desc' },
|
||||
{ accountId: 'asc' },
|
||||
{ brokerOperationId: 'asc' },
|
||||
],
|
||||
...(input.limit === undefined ? {} : { take: input.limit + CURSOR_LOOKAHEAD_ROWS }),
|
||||
});
|
||||
return {
|
||||
connection,
|
||||
periods,
|
||||
membershipWork,
|
||||
binding,
|
||||
rows,
|
||||
coverage: coverage.map(({ instrumentPredicate, ...row }) => ({
|
||||
...row,
|
||||
instrumentUid: instrumentPredicate || undefined,
|
||||
})),
|
||||
work: work.map(({ intent, ...row }) => ({
|
||||
...row,
|
||||
intervals: (intent as { intervals: string[] }).intervals.map(
|
||||
decodeInterval,
|
||||
),
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async coverage(userId: string, revision: string, accountIds: string[]) {
|
||||
const rows = await this.prisma.tbankOperationCoverage.findMany({
|
||||
where: {
|
||||
@@ -268,16 +91,10 @@ export class BrokerageOperationsRepository {
|
||||
.update(JSON.stringify([...new Set(intervals.map(intervalKey))].sort()))
|
||||
.digest('hex');
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const locked = await tx.tbankConnection.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
invalidToken: false,
|
||||
membershipAt: { not: null },
|
||||
},
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
const locked = await lockConnectionGeneration(tx, { userId, revision }, {
|
||||
membershipAt: { not: null },
|
||||
});
|
||||
if (!locked.count) return null;
|
||||
if (!locked) return null;
|
||||
const accounts = await tx.tbankAccount.findMany({
|
||||
where: { userId, revision },
|
||||
select: { accountId: true, openedOn: true },
|
||||
@@ -379,9 +196,7 @@ export class BrokerageOperationsRepository {
|
||||
publicationAtomic && previousIntent && !previousIntent.atomic;
|
||||
if (
|
||||
touching.length <= SINGLE_WORK_GROUP &&
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
runningAttempts.has(previous.attemptId) &&
|
||||
isJoinableRunningAttempt(previous, processRunId, runningAttempts) &&
|
||||
!upgradeGroup
|
||||
) {
|
||||
const pending = previousIntent?.intervals.map(decodeInterval) ?? [];
|
||||
@@ -656,9 +471,7 @@ export class BrokerageOperationsRepository {
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const payload = JSON.parse(
|
||||
JSON.stringify({ ...operation, cursor: undefined }),
|
||||
) as Prisma.InputJsonObject;
|
||||
const payload = toJson({ ...operation, cursor: undefined });
|
||||
// Provider pagination position is not part of a financial row.
|
||||
const identity = { ...stage, itemKey: operation.id };
|
||||
const previous = await tx.tbankSyncStage.findUnique({
|
||||
@@ -882,74 +695,10 @@ export class BrokerageOperationsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
function extendRunningIntent(input: {
|
||||
additions: OperationInterval[];
|
||||
intervals: OperationInterval[];
|
||||
pending: OperationInterval[];
|
||||
previous: OperationsIntent | undefined;
|
||||
previousPriority: number;
|
||||
priority: number;
|
||||
publicationAtomic: boolean;
|
||||
}): OperationsIntent {
|
||||
const { previous, pending, additions, intervals, priority, previousPriority, publicationAtomic } = input;
|
||||
const overlapsRequested = (item: OperationInterval) =>
|
||||
intervals.some((target) => operationIntervalsOverlap(item, target));
|
||||
const retainedScreen = previous?.screenIntervals ??
|
||||
(previousPriority === operationsSyncPriority.screen ? (previous?.intervals ?? []) : []);
|
||||
const addedScreen = priority === operationsSyncPriority.screen
|
||||
? [...pending, ...additions].filter(overlapsRequested).map(intervalKey)
|
||||
: [];
|
||||
const ordered = priority < previousPriority
|
||||
? [
|
||||
...pending.filter(overlapsRequested),
|
||||
...additions,
|
||||
...pending.filter((item) => !overlapsRequested(item)),
|
||||
]
|
||||
: [...pending, ...additions];
|
||||
return {
|
||||
atomic: previous?.atomic ?? publicationAtomic,
|
||||
atomicIntervals: previous?.atomic
|
||||
? [...new Set([...(previous.atomicIntervals ?? previous.intervals), ...additions.map(intervalKey)])]
|
||||
: [],
|
||||
screenIntervals: [...new Set([...retainedScreen, ...addedScreen])],
|
||||
intervals: ordered.map(intervalKey),
|
||||
};
|
||||
}
|
||||
|
||||
function intervalKey(interval: OperationInterval): string {
|
||||
// eslint-disable-next-line sonarjs/no-inverted-boolean-check -- Negation also rejects Invalid Date; >= would silently accept NaN.
|
||||
if (!(interval.from < interval.to)) {
|
||||
throw new Error('Invalid operations interval');
|
||||
}
|
||||
return JSON.stringify([
|
||||
interval.accountId,
|
||||
interval.from.toISOString(),
|
||||
interval.to.toISOString(),
|
||||
interval.instrumentUid ?? '',
|
||||
]);
|
||||
}
|
||||
|
||||
async function claim(
|
||||
tx: Prisma.TransactionClient,
|
||||
attempt: OperationAttempt,
|
||||
): Promise<boolean> {
|
||||
const result = await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
...attempt,
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { updatedAt: new Date() },
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_WORK_ITEMS;
|
||||
}
|
||||
|
||||
function decodeInterval(key: string): OperationInterval {
|
||||
const [accountId, from, to, instrumentUid] = JSON.parse(key) as string[];
|
||||
return {
|
||||
accountId,
|
||||
from: new Date(from),
|
||||
to: new Date(to),
|
||||
...(instrumentUid ? { instrumentUid } : {}),
|
||||
};
|
||||
const count = await claimSyncWork(tx, attempt, { updatedAt: new Date() });
|
||||
return count === EXPECTED_UPDATED_WORK_ITEMS;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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 { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
|
||||
import { BrokerageOperationsService } from './application/brokerage-operations.service';
|
||||
import { LocalOperationsCursorCodec } from './application/local-operations-cursor.codec';
|
||||
import { BrokerageOperationsReadRepository } from './infrastructure/brokerage-operations-read.repository';
|
||||
import { BrokerageOperationsRepository } from './infrastructure/brokerage-operations.repository';
|
||||
import { BrokerageOperationsController } from './presentation/brokerage-operations.controller';
|
||||
|
||||
@@ -14,6 +17,8 @@ import { BrokerageOperationsController } from './presentation/brokerage-operatio
|
||||
providers: [
|
||||
BrokerageOperationsService,
|
||||
BrokerageOperationsRepository,
|
||||
BrokerageOperationsReadRepository,
|
||||
LocalOperationsCursorCodec,
|
||||
BrokerageOperationsSyncService,
|
||||
],
|
||||
exports: [BrokerageOperationsService, BrokerageOperationsSyncService],
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
import { operationCategories, type OperationCategory } from '../../operation-category';
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class PrismaService {},
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { BrokerageOperationsService } from '../application/brokerage-operations.service';
|
||||
import {
|
||||
BrokerageOperationsQueryDto,
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { add, decimalFromString, formatDecimal } from '#src/common/decimal';
|
||||
|
||||
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
ForecastRepository,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import assert from 'node:assert/strict';
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({
|
||||
jest.mock('#src/prisma/prisma.service', () => ({
|
||||
PrismaService: class {},
|
||||
}));
|
||||
import { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
@@ -4,10 +4,10 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
metadataKey,
|
||||
@@ -17,7 +17,8 @@ import {
|
||||
type TbankMoney,
|
||||
type TbankPortfolio,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import {
|
||||
hourlyAttempt,
|
||||
|
||||
+4
-3
@@ -1,8 +1,9 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
|
||||
jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { InstrumentIdType } from '#src/integrations/tbank/tbank-protocol';
|
||||
|
||||
import { PositionClassificationService } from './position-classification.service';
|
||||
|
||||
describe('PositionClassificationService', () => {
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
|
||||
import { isZero } from '../../../../common/decimal';
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { metadataKey, TbankClient, type TbankPortfolio } from '../../../../integrations/tbank/tbank.api';
|
||||
import { isZero } from '#src/common/decimal';
|
||||
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_DAY } from '#src/common/time';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { InstrumentIdType } from '#src/integrations/tbank/tbank-protocol';
|
||||
import { metadataKey, TbankClient, type TbankPortfolio } from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import { PositionClassificationRepository } from '../infrastructure/position-classification.repository';
|
||||
|
||||
export type Classification = { issuer: null | { id: string; name: string }; sector: null | string };
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
DECIMAL_RADIX,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
} from '#src/common/decimal';
|
||||
|
||||
const ZERO_AMOUNT = 0n;
|
||||
const INITIAL_UNVALUED_COUNT = 0;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
decimalFromString,
|
||||
formatDecimal,
|
||||
} from '../../../../common/decimal';
|
||||
} from '#src/common/decimal';
|
||||
|
||||
export function sumMoneyByCurrency(amounts: Money[]): Money[] {
|
||||
const totals = new Map<string, ReturnType<typeof decimalFromString>>();
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
|
||||
@@ -142,13 +143,11 @@ export class ForecastRepository {
|
||||
) {
|
||||
return fallback;
|
||||
}
|
||||
const revisions = JSON.parse(
|
||||
JSON.stringify({
|
||||
portfolioProjection: input.portfolioProjection,
|
||||
portfolios: input.portfolios,
|
||||
sources: input.sources,
|
||||
}),
|
||||
) as Prisma.InputJsonObject;
|
||||
const revisions = toJson({
|
||||
portfolioProjection: input.portfolioProjection,
|
||||
portfolios: input.portfolios,
|
||||
sources: input.sources,
|
||||
});
|
||||
const timestamps = input.timestamps as Prisma.InputJsonArray;
|
||||
if (
|
||||
fallback?.scopeKey === input.scopeKey &&
|
||||
@@ -159,9 +158,7 @@ export class ForecastRepository {
|
||||
}
|
||||
const data = {
|
||||
membershipRevision: input.membershipRevision,
|
||||
payload: JSON.parse(
|
||||
JSON.stringify(input.candidate),
|
||||
) as Prisma.InputJsonObject,
|
||||
payload: toJson(input.candidate),
|
||||
dependencyRevisions: revisions,
|
||||
sourceTimestamps: timestamps,
|
||||
calculatedAt: new Date(),
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
import type { Prisma } from '#generated/prisma/client';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
|
||||
import { lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
|
||||
import { isJoinableRunningAttempt, tbankSourceNeedsRefresh, tbankSourceTtlMs } from '#src/integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
import { toJson } from '#src/prisma/to-json';
|
||||
|
||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||
|
||||
@@ -56,16 +59,10 @@ export class PortfolioRepository {
|
||||
force: boolean,
|
||||
) {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const current = await tx.tbankConnection.updateMany({
|
||||
where: {
|
||||
userId,
|
||||
revision,
|
||||
invalidToken: false,
|
||||
accounts: { some: { accountId } },
|
||||
},
|
||||
data: { membershipRevision: { increment: 0 } },
|
||||
const current = await lockConnectionGeneration(tx, { userId, revision }, {
|
||||
accounts: { some: { accountId } },
|
||||
});
|
||||
if (!current.count) return null;
|
||||
if (!current) return null;
|
||||
const snapshot = await tx.tbankPortfolio.findUnique({
|
||||
where: { userId_revision_accountId: { userId, revision, accountId } },
|
||||
});
|
||||
@@ -86,11 +83,7 @@ export class PortfolioRepository {
|
||||
return null;
|
||||
}
|
||||
const scope = portfolioWorkScope;
|
||||
if (
|
||||
previous?.state === 'running' &&
|
||||
previous.processRunId === processRunId &&
|
||||
pending.has(previous.attemptId)
|
||||
) {
|
||||
if (isJoinableRunningAttempt(previous, processRunId, pending)) {
|
||||
return { work: previous, launch: false };
|
||||
}
|
||||
const data = {
|
||||
@@ -137,7 +130,7 @@ export class PortfolioRepository {
|
||||
accountId,
|
||||
};
|
||||
const data = {
|
||||
payload: JSON.parse(JSON.stringify(snapshot)) as Prisma.InputJsonObject,
|
||||
payload: toJson(snapshot),
|
||||
fetchedAt: new Date(snapshot.lastSuccessfulAt),
|
||||
};
|
||||
await tx.tbankPortfolio.upsert({
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import { PrismaService } from '#src/prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class PositionClassificationRepository {
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
import type { Money } from '../../../../common/money.types';
|
||||
import type { Classification } from '../application/position-classification.service';
|
||||
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||
import type { Money } from '#src/common/money.types';
|
||||
|
||||
import {
|
||||
add,
|
||||
@@ -9,15 +7,19 @@ import {
|
||||
isZero,
|
||||
multiply,
|
||||
subtract,
|
||||
} from '../../../../common/decimal';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
} from '#src/common/decimal';
|
||||
import { decimal } from '#src/integrations/tbank/decimal';
|
||||
import {
|
||||
metadataKey,
|
||||
type TbankInstrument,
|
||||
type TbankMoney,
|
||||
TbankPortfolio,
|
||||
TbankPositions,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
} from '#src/integrations/tbank/tbank.api';
|
||||
|
||||
import type { Classification } from '../application/position-classification.service';
|
||||
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
|
||||
|
||||
import { portfolioStructure } from '../domain/portfolio-structure';
|
||||
|
||||
const types: Record<string, string> = {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { TbankModule } from '../../../integrations/tbank/tbank.module';
|
||||
import { TbankModule } from '#src/integrations/tbank/tbank.module';
|
||||
|
||||
import { AuthModule } from '../../auth/auth.module';
|
||||
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
|
||||
import { ForecastService } from './application/forecast.service';
|
||||
|
||||
@@ -3,8 +3,9 @@ import type { Request } from 'express';
|
||||
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
|
||||
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api';
|
||||
import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
|
||||
import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
|
||||
|
||||
import { PortfolioService } from '../application/portfolio.service';
|
||||
import { PortfolioResourceDto } from './portfolio.dto';
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { MoneyDto } from '#src/common/money.dto';
|
||||
|
||||
import { AccountReferenceDto } from '../../account-reference.dto';
|
||||
import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
|
||||
|
||||
|
||||
@@ -18,7 +18,8 @@ import {
|
||||
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 {
|
||||
PutTbankConnectionDto,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user