diff --git a/.ai/architecture.md b/.ai/architecture.md index a0440a2..78a0c26 100644 --- a/.ai/architecture.md +++ b/.ai/architecture.md @@ -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). diff --git a/.ai/conventions/data.md b/.ai/conventions/data.md index b79cc76..ea74b2f 100644 --- a/.ai/conventions/data.md +++ b/.ai/conventions/data.md @@ -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 diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml index d5fc691..cb3b630 100644 --- a/.gitea/workflows/verify.yml +++ b/.gitea/workflows/verify.yml @@ -67,7 +67,7 @@ jobs: - name: Audit runtime and development dependencies run: | npm audit --omit=dev --audit-level=low - npm audit --include=dev --audit-level=high --ignore-advisory=GHSA-hp3w-g68c-fv3c + npm audit --include=dev --audit-level=high verify: needs: [commits, security] diff --git a/CHANGELOG.md b/CHANGELOG.md index db66732..b7485e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/eslint-suppressions.json b/eslint-suppressions.json index af6f170..51f2439 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -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 } } } \ No newline at end of file diff --git a/eslint.config.mjs b/eslint.config.mjs index d5a9e23..b2195e8 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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.`, + })), }], }, - }, + })), ); diff --git a/package-lock.json b/package-lock.json index 8d9e113..749c8eb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 822929f..a960460 100644 --- a/package.json +++ b/package.json @@ -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/(.*)$": "/../generated/$1", + "^#src/(.*)$": "/$1" + }, "moduleFileExtensions": [ "js", "json", diff --git a/renovate.json b/renovate.json index 7e8c8e5..3b8d78e 100644 --- a/renovate.json +++ b/renovate.json @@ -2,7 +2,8 @@ "$schema": "https://docs.renovatebot.com/renovate-schema.json", "extends": [ "config:recommended", - ":semanticCommitTypeAll(chore)" + ":semanticCommitTypeAll(chore)", + "security:minimumReleaseAgeNpm" ], "timezone": "Europe/Moscow", "labels": [ @@ -10,6 +11,13 @@ ], "dependencyDashboard": true, "rangeStrategy": "pin", + "prConcurrentLimit": 5, + "lockFileMaintenance": { + "enabled": true, + "schedule": [ + "before 4am on the first day of the month" + ] + }, "packageRules": [ { "matchUpdateTypes": [ @@ -18,6 +26,27 @@ ], "groupName": "non-major dependencies" }, + { + "matchManagers": [ + "github-actions" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "non-major github actions" + }, + { + "matchManagers": [ + "docker-compose", + "dockerfile" + ], + "matchUpdateTypes": [ + "minor", + "patch" + ], + "groupName": "non-major container images" + }, { "matchUpdateTypes": [ "major" diff --git a/src/integrations/tbank/instrument-metadata.repository.ts b/src/integrations/tbank/instrument-metadata.repository.ts index 93577d9..ace5c0f 100644 --- a/src/integrations/tbank/instrument-metadata.repository.ts +++ b/src/integrations/tbank/instrument-metadata.repository.ts @@ -5,13 +5,15 @@ import { isDeepStrictEqual } from 'node:util'; import type { TbankInstrumentLookup as MetadataLookupRow, TbankInstrument as MetadataRow, - Prisma, -} from '../../../generated/prisma/client'; -import type { TbankSyncAttempt } from './tbank-access.service'; +} from '#generated/prisma/client'; + import type { TbankInstrument } from './tbank.client'; import { PrismaService } from '../../prisma/prisma.service'; +import { toJson } from '../../prisma/to-json'; +import { isJoinableRunningAttempt, type TbankSyncAttempt } from './tbank-access.service'; import { TbankErrorCode } from './tbank-error-code'; +import { 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, ) { 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)}`, @@ -131,11 +130,7 @@ export class InstrumentMetadataRepository { const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, }); - if ( - previous?.state === 'running' && - previous.processRunId === runId && - active.has(previous.attemptId) - ) { + if (isJoinableRunningAttempt(previous, runId, active)) { return { work: previous, launch: false }; } const data = { @@ -171,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]), ); @@ -216,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: @@ -268,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, diff --git a/src/integrations/tbank/tbank-access.service.ts b/src/integrations/tbank/tbank-access.service.ts index 06e22d2..adcc5ed 100644 --- a/src/integrations/tbank/tbank-access.service.ts +++ b/src/integrations/tbank/tbank-access.service.ts @@ -12,6 +12,7 @@ import { PrismaService } from '../../prisma/prisma.service'; import { supportedTbankAccounts } from './tbank-account.policy'; import { TbankErrorCode } from './tbank-error-code'; import { tbankSourceNeedsRefresh } from './tbank-source.policy'; +import { claimSyncWork, lockConnectionGeneration } from './tbank-sync-claim'; import { TbankTokenService } from './tbank-token.service'; const EXPECTED_UPDATED_CONNECTIONS = 1; @@ -25,6 +26,22 @@ export type TbankSyncAttempt = { userId: string; }; +// Join rule shared by sync reservations: the same process joins its own running attempt +// only while that attempt is still active in memory. Set and Map both satisfy `has`. +export function isJoinableRunningAttempt< + T extends { attemptId: string; processRunId: string; state: string }, +>( + previous: null | T, + processRunId: string, + active: { has(attemptId: string): boolean }, +): previous is T { + return ( + previous?.state === 'running' && + previous.processRunId === processRunId && + active.has(previous.attemptId) + ); +} + @Injectable() export class TbankAccessService { constructor( @@ -40,11 +57,8 @@ export class TbankAccessService { force: boolean, ) { return this.prisma.$transaction(async (tx) => { - const current = await tx.tbankConnection.updateMany({ - where: { userId, revision, invalidToken: false }, - data: { membershipRevision: { increment: 0 } }, - }); - if (!current.count) return null; + const current = await lockConnectionGeneration(tx, { userId, revision }); + if (!current) return null; const connection = await tx.tbankConnection.findUniqueOrThrow({ where: { userId }, }); @@ -62,11 +76,7 @@ export class TbankAccessService { const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, }); - if ( - previous?.state === 'running' && - previous.processRunId === processRunId && - active.has(previous.attemptId) - ) { + if (isJoinableRunningAttempt(previous, processRunId, active)) { return { work: previous, launch: false }; } const data = { @@ -98,15 +108,11 @@ export class TbankAccessService { async failMembership(attempt: TbankSyncAttempt, invalidToken: boolean) { if (invalidToken) return this.rejectMembershipToken(attempt); - const result = await this.prisma.tbankSyncWork.updateMany({ - where: { - ...attempt, - state: 'running', - connection: { invalidToken: false }, - }, - data: { state: 'failed', errorCode: TbankErrorCode.Unavailable }, + const result = await claimSyncWork(this.prisma, attempt, { + state: 'failed', + errorCode: TbankErrorCode.Unavailable, }); - return result.count === EXPECTED_UPDATED_CONNECTIONS; + return result === EXPECTED_UPDATED_CONNECTIONS; } async tokenForAttempt(attempt: TbankSyncAttempt): Promise { @@ -151,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 }, @@ -232,11 +231,11 @@ export class TbankAccessService { async rejectMembershipToken(attempt: TbankSyncAttempt): Promise { 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: { diff --git a/src/integrations/tbank/tbank-sync-claim.ts b/src/integrations/tbank/tbank-sync-claim.ts new file mode 100644 index 0000000..d8282e3 --- /dev/null +++ b/src/integrations/tbank/tbank-sync-claim.ts @@ -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 = {}, +): Promise { + 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 { + const result = await tx.tbankConnection.updateMany({ + where: { ...owner, ...extra, invalidToken: false }, + data: { membershipRevision: { increment: 0 } }, + }); + return result.count > NO_UPDATED_ROWS; +} diff --git a/src/integrations/tbank/tbank.api.ts b/src/integrations/tbank/tbank.api.ts index 1f297d5..ecf0e3c 100644 --- a/src/integrations/tbank/tbank.api.ts +++ b/src/integrations/tbank/tbank.api.ts @@ -3,6 +3,7 @@ export { InstrumentMetadataService } from './instrument-metadata.service'; export { metadataKey } from './instrument-metadata.service'; export type { MetadataIdentity } from './instrument-metadata.service'; export { + isJoinableRunningAttempt, TbankAccessService, type TbankSyncAttempt, } from './tbank-access.service'; diff --git a/src/modules/auth/application/auth.service.spec.ts b/src/modules/auth/application/auth.service.spec.ts index 3e04a6d..b471e7f 100644 --- a/src/modules/auth/application/auth.service.spec.ts +++ b/src/modules/auth/application/auth.service.spec.ts @@ -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(); diff --git a/src/modules/auth/application/auth.service.ts b/src/modules/auth/application/auth.service.ts index f19cbc2..69cdfcc 100644 --- a/src/modules/auth/application/auth.service.ts +++ b/src/modules/auth/application/auth.service.ts @@ -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 { - 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 { - 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 { - 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 { - 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) - ); -} diff --git a/src/modules/auth/auth.module.ts b/src/modules/auth/auth.module.ts index 1d93f41..ccef8e5 100644 --- a/src/modules/auth/auth.module.ts +++ b/src/modules/auth/auth.module.ts @@ -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 {} diff --git a/src/modules/auth/infrastructure/auth.repository.ts b/src/modules/auth/infrastructure/auth.repository.ts new file mode 100644 index 0000000..557a8ca --- /dev/null +++ b/src/modules/auth/infrastructure/auth.repository.ts @@ -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 { + 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) + ); +} diff --git a/src/modules/auth/infrastructure/jwt.strategy.ts b/src/modules/auth/infrastructure/jwt.strategy.ts index ce4cfd9..d274985 100644 --- a/src/modules/auth/infrastructure/jwt.strategy.ts +++ b/src/modules/auth/infrastructure/jwt.strategy.ts @@ -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() diff --git a/src/modules/auth/presentation/auth.controller.ts b/src/modules/auth/presentation/auth.controller.ts index bfa4c5b..4c87ae2 100644 --- a/src/modules/auth/presentation/auth.controller.ts +++ b/src/modules/auth/presentation/auth.controller.ts @@ -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'; diff --git a/src/modules/banking/bank-cards/application/bank-card-input.ts b/src/modules/banking/bank-cards/application/bank-card-input.ts index 0fe28ac..d458182 100644 --- a/src/modules/banking/bank-cards/application/bank-card-input.ts +++ b/src/modules/banking/bank-cards/application/bank-card-input.ts @@ -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, diff --git a/src/modules/banking/bank-cards/application/bank-cards.service.ts b/src/modules/banking/bank-cards/application/bank-cards.service.ts index e1e6d74..494c173 100644 --- a/src/modules/banking/bank-cards/application/bank-cards.service.ts +++ b/src/modules/banking/bank-cards/application/bank-cards.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; -import { BankCard, Prisma } from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; +import type { BankCard, Prisma } from '#generated/prisma/client'; + import { bankBalance } from '../domain/bank-card.calculations'; import { rubMoney } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; @@ -20,24 +20,13 @@ import { missing, } from './bank-card-input'; -const EXPECTED_UPDATED_CARDS = 1; - type Tx = Prisma.TransactionClient; @Injectable() export class BankCardsService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} private async cardView(tx: Tx, card: BankCard): Promise { - const rows = await tx.bankCardOperation.findMany({ - where: { - userId: card.userId, - deletedAt: null, - OR: [{ cardId: card.id }, { targetCardId: card.id }], - }, - }); + const rows = await this.repository.cardOperations(tx, card); return { id: card.id, name: card.name, @@ -53,20 +42,15 @@ export class BankCardsService { } list(userId: string, includeArchived = false) { - return this.prisma.$transaction(async (tx) => - Promise.all( - ( - await tx.bankCard.findMany({ - where: { userId, ...(includeArchived ? {} : { archivedAt: null }) }, - orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], - }) - ).map((card) => this.cardView(tx, card)), - ), + return this.repository.read(async (tx) => + Promise.all((await this.repository.cards(tx, userId, includeArchived)).map( + (card) => this.cardView(tx, card), + )), ); } get(userId: string, id: string) { - return this.prisma.$transaction(async (tx) => + return this.repository.read(async (tx) => this.cardView(tx, await this.repository.card(tx, userId, id)), ); } @@ -75,7 +59,7 @@ export class BankCardsService { const input = cardInput(dto), hash = fingerprint(input); return this.repository.write(async (tx) => { - const existing = await tx.bankCard.findUnique({ where: { id: dto.id } }); + const existing = await this.repository.cardById(tx, dto.id); if (existing) { if (existing.userId !== userId) missing(); // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. @@ -88,8 +72,11 @@ export class BankCardsService { await this.repository.seed(tx, userId); return this.cardView( tx, - await tx.bankCard.create({ - data: { ...input, id: dto.id, userId, createFingerprint: hash }, + await this.repository.createCard(tx, { + ...input, + id: dto.id, + userId, + createFingerprint: hash, }), ); }); @@ -122,20 +109,16 @@ export class BankCardsService { } currentDate(input.openingDate); if ( - await tx.bankCardOperation.findFirst({ - where: { - userId, - deletedAt: null, - date: { lt: input.openingDate }, - OR: [{ cardId: id }, { targetCardId: id }], - }, - }) + await this.repository.hasOperationsBefore(tx, userId, id, input.openingDate) ) { fail(BankErrorCode.OpeningDateConflict); } - const changed = await tx.bankCard.updateMany({ - where: { id, userId, version: dto.version }, - data: { + const updated = await this.repository.updateCard( + tx, + userId, + id, + dto.version, + { ...input, paymentSystem: input.paymentSystem ?? null, version: { increment: 1 }, @@ -147,9 +130,8 @@ export class BankCardsService { : null, }), }, - }); - if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); - return this.cardView(tx, await this.repository.card(tx, userId, id)); + ); + return this.cardView(tx, updated); }); } } diff --git a/src/modules/banking/bank-cards/application/bank-operations.service.ts b/src/modules/banking/bank-cards/application/bank-operations.service.ts index e562780..ed1ca62 100644 --- a/src/modules/banking/bank-cards/application/bank-operations.service.ts +++ b/src/modules/banking/bank-cards/application/bank-operations.service.ts @@ -1,12 +1,16 @@ import { GoneException, Injectable } from '@nestjs/common'; -import { +import type { BankCardOperation, Prisma, -} from '../../../../../generated/prisma/client'; -import { PrismaService } from '../../../../prisma/prisma.service'; +} from '#generated/prisma/client'; + import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; +import { + operationFieldConflict, + operationKindChanged, +} from '../domain/bank-operation.rules'; import { rubMoney, ZERO_KOPECKS } from '../domain/rub-money'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { @@ -28,117 +32,22 @@ import { periodBounds, } from './bank-card-input'; -const EXPECTED_UPDATED_ROWS = 1; - type Tx = Prisma.TransactionClient; @Injectable() export class BankOperationsService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} private async operationView( tx: Tx, row: BankCardOperation, ): Promise { - const card = await this.repository.card(tx, row.userId, row.cardId); - const target = row.targetCardId - ? await this.repository.card(tx, row.userId, row.targetCardId) - : null; - const source = row.originalExpenseId - ? await this.repository.operation(tx, row.userId, row.originalExpenseId) - : null; - const categoryId = source?.categoryId ?? row.categoryId; - const category = categoryId - ? await tx.expenseCategory.findFirst({ - where: { id: categoryId, userId: row.userId }, - }) - : null; - const refunds = - row.kind === BankOperationKind.Expense || source - ? await tx.bankCardOperation.findMany({ - where: { - userId: row.userId, - originalExpenseId: source?.id ?? row.id, - deletedAt: null, - ...(source ? { id: { not: row.id } } : {}), - }, - }) - : []; - return { - id: row.id, - kind: row.kind as BankOperationDto['kind'], - cardId: row.cardId, - cardName: card.name, - targetCardId: row.targetCardId, - targetCardName: target?.name ?? null, - categoryId, - categoryName: category?.name ?? null, - originalExpenseId: row.originalExpenseId, - amount: rubMoney(row.amountKopecks), - availableRefund: - 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[]) { const userId = rows[0]?.userId; if (!userId) return []; - const cardIds = new Set( - rows.flatMap((row) => - [row.cardId, row.targetCardId].filter( - (id): id is string => id !== null, - ), - ), - ); - const sourceIds = new Set( - rows.flatMap((row) => (row.originalExpenseId ? [row.originalExpenseId] : [])), - ); - const [cards, sources] = await Promise.all([ - tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }), - tx.bankCardOperation.findMany({ - where: { userId, id: { in: [...sourceIds] } }, - }), - ]); - const categoryIds = new Set( - rows - .map( - (row) => - sources.find((source) => source.id === row.originalExpenseId) - ?.categoryId ?? row.categoryId, - ) - .filter((id): id is string => id !== null), - ); - const expenseIds = new Set( - rows.flatMap((row) => { - const source = sources.find((item) => item.id === row.originalExpenseId); - return row.kind === BankOperationKind.Expense || source - ? [source?.id ?? row.id] - : []; - }), - ); - const [categories, refunds] = await Promise.all([ - tx.expenseCategory.findMany({ - where: { userId, id: { in: [...categoryIds] } }, - }), - tx.bankCardOperation.findMany({ - where: { - userId, - originalExpenseId: { in: [...expenseIds] }, - deletedAt: null, - }, - }), - ]); + const { cards, sources, categories, refunds } = await this.repository.operationViewData(tx, userId, rows); const cardsById = new Map(cards.map((card) => [card.id, card])); const sourcesById = new Map(sources.map((source) => [source.id, source])); const categoriesById = new Map(categories.map((category) => [category.id, category])); @@ -207,49 +116,28 @@ export class BankOperationsService { fail(BankErrorCode.InvalidDate); } } - if ( - old && - (old.kind !== input.kind || - old.originalExpenseId !== input.originalExpenseId) - ) { + if (old && operationKindChanged(old.kind, input.kind, + old.originalExpenseId, input.originalExpenseId)) { fail(BankErrorCode.ImmutableOperation); } - if (input.kind === BankOperationKind.Transfer) { - if ( - !input.targetCardId || - input.targetCardId === input.cardId || - input.categoryId || - input.originalExpenseId - ) { - fail(); - } - } else if (input.targetCardId) fail(); + if (operationFieldConflict(input)) fail(); if (input.kind === BankOperationKind.Expense) { - if (input.originalExpenseId) fail(); if (!input.categoryId) { - const category = await tx.expenseCategory.findUnique({ - where: { userId_seedKey: { userId, seedKey: 'other' } }, - }); + const category = await this.repository.defaultCategory(tx, userId); if (!category) missing(); input.categoryId = category.id; } if ( - !(await tx.expenseCategory.findFirst({ - where: { userId, id: input.categoryId }, - })) + !(await this.repository.category(tx, userId, input.categoryId)) ) { missing(); } - } else if (input.categoryId) fail(); - if (input.kind !== BankOperationKind.Refund && input.originalExpenseId) { - fail(); } if (input.kind === BankOperationKind.Refund) { - if (!input.originalExpenseId) fail(); const source = await this.repository.operation( tx, userId, - input.originalExpenseId, + input.originalExpenseId as string, ); if ( source.deletedAt || @@ -258,29 +146,11 @@ export class BankOperationsService { ) { fail(BankErrorCode.InvalidRefund); } - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { - id: source.id, - userId, - version: source.version, - deletedAt: null, - }, - data: { version: { increment: 1 } }, - }) - ).count !== EXPECTED_UPDATED_ROWS - ) { - conflict(); - } - const refunds = await tx.bankCardOperation.findMany({ - where: { - userId, - originalExpenseId: source.id, - deletedAt: null, - ...(old ? { id: { not: old.id } } : {}), - }, - }); + await this.repository.updateOperation(tx, + { id: source.id, userId, version: source.version, deletedAt: null }, + { version: { increment: 1 } }, + ); + const refunds = await this.repository.refunds(tx, userId, source.id, old?.id); if (input.date < source.date) fail(BankErrorCode.InvalidDate); if ( refunds.reduce((sum, r) => sum + r.amountKopecks, ZERO_KOPECKS) + @@ -291,9 +161,7 @@ export class BankOperationsService { } } if (old?.kind === BankOperationKind.Expense) { - const refunds = await tx.bankCardOperation.findMany({ - where: { userId, originalExpenseId: old.id, deletedAt: null }, - }); + const refunds = await this.repository.refunds(tx, userId, old.id); if ( refunds.length && (input.cardId !== old.cardId || @@ -310,9 +178,7 @@ export class BankOperationsService { const normalized = operationInput(dto), hash = fingerprint(normalized); return this.repository.write(async (tx) => { - const existing = await tx.bankCardOperation.findUnique({ - where: { id: dto.id }, - }); + const existing = await this.repository.operationById(tx, dto.id); if (existing) { if (existing.userId !== userId) missing(); // eslint-disable-next-line security/detect-possible-timing-attacks -- This compares an idempotency fingerprint after ownership validation, not a secret. @@ -328,15 +194,15 @@ export class BankOperationsService { await this.validateOperation(tx, userId, input); return this.operationView( tx, - await tx.bankCardOperation.create({ - data: { ...input, id: dto.id, userId, createFingerprint: hash }, + await this.repository.createOperation(tx, { + ...input, id: dto.id, userId, createFingerprint: hash, }), ); }); } getOperation(userId: string, id: string) { - return this.prisma.$transaction(async (tx) => { + return this.repository.read(async (tx) => { const row = await this.repository.operation(tx, userId, id); if (row.deletedAt) missing(); return this.operationView(tx, row); @@ -361,16 +227,10 @@ export class BankOperationsService { description: dto.description ?? old.description ?? undefined, }); await this.validateOperation(tx, userId, input, old); - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { id, userId, version: dto.version, deletedAt: null }, - data: { ...input, version: { increment: 1 } }, - }) - ).count !== EXPECTED_UPDATED_ROWS - ) { - conflict(); - } + await this.repository.updateOperation(tx, + { id, userId, version: dto.version, deletedAt: null }, + { ...input, version: { increment: 1 } }, + ); return this.operationView( tx, await this.repository.operation(tx, userId, id), @@ -389,9 +249,7 @@ export class BankOperationsService { } if ( row.kind === BankOperationKind.Expense && - (await tx.bankCardOperation.findFirst({ - where: { userId, originalExpenseId: id, deletedAt: null }, - })) + (await this.repository.dependentRefund(tx, userId, id)) ) { conflict(BankErrorCode.DependentRefunds); } @@ -401,27 +259,15 @@ export class BankOperationsService { userId, row.originalExpenseId, ); - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { id: source.id, userId, version: source.version }, - data: { version: { increment: 1 } }, - }) - ).count !== EXPECTED_UPDATED_ROWS - ) { - conflict(); - } - } - if ( - ( - await tx.bankCardOperation.updateMany({ - where: { id, userId, version, deletedAt: null }, - data: { deletedAt: new Date(), version: { increment: 1 } }, - }) - ).count !== EXPECTED_UPDATED_ROWS - ) { - conflict(); + await this.repository.updateOperation(tx, + { id: source.id, userId, version: source.version }, + { version: { increment: 1 } }, + ); } + await this.repository.updateOperation(tx, + { id, userId, version, deletedAt: null }, + { deletedAt: new Date(), version: { increment: 1 } }, + ); }); } @@ -434,25 +280,13 @@ export class BankOperationsService { const period = expenses ? undefined : periodBounds(query as BankOperationsQueryDto); - return this.prisma.$transaction(async (tx) => { + return this.repository.read(async (tx) => { await this.repository.card(tx, userId, id); const limit = query.limit ?? DEFAULT_BANK_PAGE_SIZE, page = query.page ?? FIRST_PAGE; - const where: Prisma.BankCardOperationWhereInput = { - userId, - deletedAt: null, - OR: [{ cardId: id }, { targetCardId: id }], - ...(period - ? { date: { gte: period.from, lte: period.to } } - : { kind: BankOperationKind.Expense }), - }; - const total = await tx.bankCardOperation.count({ where }); - const rows = await tx.bankCardOperation.findMany({ - where, - orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], - skip: (page - FIRST_PAGE) * limit, - take: limit, - }); + const { total, rows } = await this.repository.operationPage( + tx, userId, id, period, limit, page, + ); return { items: await this.pageViews(tx, rows), total, diff --git a/src/modules/banking/bank-cards/application/expense-categories.service.ts b/src/modules/banking/bank-cards/application/expense-categories.service.ts index 4dbad85..65dadef 100644 --- a/src/modules/banking/bank-cards/application/expense-categories.service.ts +++ b/src/modules/banking/bank-cards/application/expense-categories.service.ts @@ -1,81 +1,24 @@ import { Injectable } from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; -import { PrismaService } from '../../../../prisma/prisma.service'; import { MAX_CATEGORY_NAME_LENGTH } from '../domain/bank-card-limits'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; -import { conflict, missing, name } from './bank-card-input'; - -const EXPECTED_UPDATED_CATEGORIES = 1; -const VERSION_INCREMENT = 1; +import { name } from './bank-card-input'; @Injectable() export class ExpenseCategoriesService { - constructor( - private readonly prisma: PrismaService, - private readonly repository: BankCardsRepository, - ) {} + constructor(private readonly repository: BankCardsRepository) {} async categories(userId: string) { - return ( - await this.prisma.expenseCategory.findMany({ - where: { userId }, - orderBy: [{ name: 'asc' }, { id: 'asc' }], - }) - ).map(({ id, name, seedKey, version }) => ({ - id, - name, - isDefault: seedKey === 'other', - version, - })); + return this.repository.categories(userId); } createCategory(userId: string, value: string) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); - return this.repository.write(async (tx) => { - await this.repository.seed(tx, userId); - const row = await tx.expenseCategory.create({ - data: { - id: randomUUID(), - userId, - name: label, - nameKey: label.toLocaleLowerCase('ru-RU'), - }, - }); - return { - id: row.id, - name: row.name, - isDefault: false, - version: row.version, - }; - }); + return this.repository.createCategory(userId, label); } updateCategory(userId: string, id: string, value: string, version: number) { const label = name(value, MAX_CATEGORY_NAME_LENGTH); - return this.repository.write(async (tx) => { - const row = await tx.expenseCategory.findFirst({ where: { id, userId } }); - if (!row) missing(); - if ( - ( - await tx.expenseCategory.updateMany({ - where: { id, userId, version }, - data: { - name: label, - nameKey: label.toLocaleLowerCase('ru-RU'), - version: { increment: 1 }, - }, - }) - ).count !== EXPECTED_UPDATED_CATEGORIES - ) { - conflict(); - } - return { - id, - name: label, - isDefault: row.seedKey === 'other', - version: version + VERSION_INCREMENT, - }; - }); + return this.repository.updateCategory(userId, id, label, version); } } diff --git a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts index 104b311..7f49e6d 100644 --- a/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts +++ b/src/modules/banking/bank-cards/application/get-bank-summary.use-case.spec.ts @@ -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'; diff --git a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts index cba24a7..6496c86 100644 --- a/src/modules/banking/bank-cards/domain/bank-card.calculations.ts +++ b/src/modules/banking/bank-cards/domain/bank-card.calculations.ts @@ -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'; diff --git a/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts b/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts new file mode 100644 index 0000000..097f830 --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-operation.rules.spec.ts @@ -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[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); + }); +}); diff --git a/src/modules/banking/bank-cards/domain/bank-operation.rules.ts b/src/modules/banking/bank-cards/domain/bank-operation.rules.ts new file mode 100644 index 0000000..08011e8 --- /dev/null +++ b/src/modules/banking/bank-cards/domain/bank-operation.rules.ts @@ -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 + ); +} diff --git a/src/modules/banking/bank-cards/domain/rub-money.ts b/src/modules/banking/bank-cards/domain/rub-money.ts index f435958..a7c896f 100644 --- a/src/modules/banking/bank-cards/domain/rub-money.ts +++ b/src/modules/banking/bank-cards/domain/rub-money.ts @@ -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; diff --git a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts index 4f33ba9..add3ba8 100644 --- a/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts +++ b/src/modules/banking/bank-cards/infrastructure/bank-cards.repository.ts @@ -1,18 +1,24 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { Prisma } from '../../../../../generated/prisma/client'; -import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; -import { PrismaService } from '../../../../prisma/prisma.service'; +import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client'; +import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code'; +import { PrismaService } from '#src/prisma/prisma.service'; + import { BankErrorCode, conflict, missing, } from '../application/bank-card-input'; +import { FIRST_PAGE } from '../domain/bank-card-limits'; import { BankOperationKind } from '../domain/bank-operation-kind'; const INITIAL_RETRY_COUNT = 0; const LAST_SEED_OFFSET = 1; +const EXPECTED_UPDATED_CARDS = 1; +const EXPECTED_UPDATED_OPERATIONS = 1; +const EXPECTED_UPDATED_CATEGORIES = 1; +const VERSION_INCREMENT = 1; type Tx = Prisma.TransactionClient; const TRANSACTION_MAX_WAIT_MS = 10_000; @@ -32,6 +38,220 @@ const seeds = [ @Injectable() export class BankCardsRepository { constructor(private readonly prisma: PrismaService) {} + + read(run: (tx: Tx) => Promise): Promise { + return this.prisma.$transaction(run); + } + + cards(tx: Tx, userId: string, includeArchived = false) { + return tx.bankCard.findMany({ + where: { userId, ...(includeArchived ? {} : { archivedAt: null }) }, + orderBy: [{ createdAt: 'asc' }, { id: 'asc' }], + }); + } + + cardOperations(tx: Tx, card: BankCard) { + return tx.bankCardOperation.findMany({ + where: { + userId: card.userId, + deletedAt: null, + OR: [{ cardId: card.id }, { targetCardId: card.id }], + }, + }); + } + + operationById(tx: Tx, id: string) { + return tx.bankCardOperation.findUnique({ where: { id } }); + } + + createOperation(tx: Tx, data: Prisma.BankCardOperationUncheckedCreateInput) { + return tx.bankCardOperation.create({ data }); + } + + async updateOperation( + tx: Tx, + where: Prisma.BankCardOperationWhereInput, + data: Prisma.BankCardOperationUpdateManyMutationInput, + ) { + const result = await tx.bankCardOperation.updateMany({ where, data }); + if (result.count !== EXPECTED_UPDATED_OPERATIONS) conflict(); + } + + category(tx: Tx, userId: string, id: string) { + return tx.expenseCategory.findFirst({ where: { id, userId } }); + } + + defaultCategory(tx: Tx, userId: string) { + return tx.expenseCategory.findUnique({ + where: { userId_seedKey: { userId, seedKey: 'other' } }, + }); + } + + refunds(tx: Tx, userId: string, originalExpenseId: string, excludeId?: string) { + return tx.bankCardOperation.findMany({ + where: { + userId, + originalExpenseId, + deletedAt: null, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + }); + } + + dependentRefund(tx: Tx, userId: string, originalExpenseId: string) { + return tx.bankCardOperation.findFirst({ + where: { userId, originalExpenseId, deletedAt: null }, + }); + } + + async operationViewData(tx: Tx, userId: string, rows: BankCardOperation[]) { + const cardIds = new Set(rows.flatMap((row) => + [row.cardId, row.targetCardId].filter((id): id is string => id !== null), + )); + const sourceIds = new Set(rows.flatMap((row) => + row.originalExpenseId ? [row.originalExpenseId] : [], + )); + const [cards, sources] = await Promise.all([ + tx.bankCard.findMany({ where: { userId, id: { in: [...cardIds] } } }), + tx.bankCardOperation.findMany({ where: { userId, id: { in: [...sourceIds] } } }), + ]); + const categoryIds = new Set(rows.map((row) => + sources.find((source) => source.id === row.originalExpenseId)?.categoryId ?? row.categoryId, + ).filter((id): id is string => id !== null)); + const expenseIds = new Set(rows.flatMap((row) => { + const source = sources.find((item) => item.id === row.originalExpenseId); + return row.kind === BankOperationKind.Expense || source ? [source?.id ?? row.id] : []; + })); + const [categories, refunds] = await Promise.all([ + tx.expenseCategory.findMany({ where: { userId, id: { in: [...categoryIds] } } }), + tx.bankCardOperation.findMany({ + where: { userId, originalExpenseId: { in: [...expenseIds] }, deletedAt: null }, + }), + ]); + return { cards, sources, categories, refunds }; + } + + async operationPage( + tx: Tx, + userId: string, + id: string, + period: undefined | { from: string; to: string }, + limit: number, + page: number, + ) { + const where: Prisma.BankCardOperationWhereInput = { + userId, + deletedAt: null, + OR: [{ cardId: id }, { targetCardId: id }], + ...(period + ? { date: { gte: period.from, lte: period.to } } + : { kind: BankOperationKind.Expense }), + }; + const total = await tx.bankCardOperation.count({ where }); + const rows = await tx.bankCardOperation.findMany({ + where, + orderBy: [{ date: 'desc' }, { createdAt: 'desc' }, { id: 'desc' }], + skip: (page - FIRST_PAGE) * limit, + take: limit, + }); + return { total, rows }; + } + + cardById(tx: Tx, id: string) { + return tx.bankCard.findUnique({ where: { id } }); + } + + createCard(tx: Tx, data: Prisma.BankCardUncheckedCreateInput) { + return tx.bankCard.create({ data }); + } + + async updateCard( + tx: Tx, + userId: string, + id: string, + version: number, + data: Prisma.BankCardUpdateManyMutationInput, + ) { + const changed = await tx.bankCard.updateMany({ + where: { id, userId, version }, + data, + }); + if (changed.count !== EXPECTED_UPDATED_CARDS) conflict(); + return this.card(tx, userId, id); + } + + hasOperationsBefore(tx: Tx, userId: string, id: string, openingDate: string) { + return tx.bankCardOperation.findFirst({ + where: { + userId, + deletedAt: null, + date: { lt: openingDate }, + OR: [{ cardId: id }, { targetCardId: id }], + }, + }); + } + + async categories(userId: string) { + return ( + await this.prisma.expenseCategory.findMany({ + where: { userId }, + orderBy: [{ name: 'asc' }, { id: 'asc' }], + }) + ).map(({ id, name, seedKey, version }) => ({ + id, + name, + isDefault: seedKey === 'other', + version, + })); + } + + createCategory(userId: string, label: string) { + return this.write(async (tx) => { + await this.seed(tx, userId); + const row = await tx.expenseCategory.create({ + data: { + id: randomUUID(), + userId, + name: label, + nameKey: label.toLocaleLowerCase('ru-RU'), + }, + }); + return { + id: row.id, + name: row.name, + isDefault: false, + version: row.version, + }; + }); + } + + updateCategory(userId: string, id: string, label: string, version: number) { + return this.write(async (tx) => { + const row = await tx.expenseCategory.findFirst({ where: { id, userId } }); + if (!row) missing(); + if ( + ( + await tx.expenseCategory.updateMany({ + where: { id, userId, version }, + data: { + name: label, + nameKey: label.toLocaleLowerCase('ru-RU'), + version: { increment: 1 }, + }, + }) + ).count !== EXPECTED_UPDATED_CATEGORIES + ) { + conflict(); + } + return { + id, + name: label, + isDefault: row.seedKey === 'other', + version: version + VERSION_INCREMENT, + }; + }); + } + async write(run: (tx: Tx) => Promise): Promise { for (let attempt = INITIAL_RETRY_COUNT; ; attempt++) { try { diff --git a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts index cb44a0f..8bced0e 100644 --- a/src/modules/banking/bank-cards/presentation/bank-card.dto.ts +++ b/src/modules/banking/bank-cards/presentation/bank-card.dto.ts @@ -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 }, diff --git a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts index 0a3e19a..cd1b767 100644 --- a/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts +++ b/src/modules/banking/bank-cards/presentation/bank-cards.controller.ts @@ -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, diff --git a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts index a661d1b..838fea3 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-account.dto.ts @@ -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'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts index 712fed4..46de791 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.controller.ts @@ -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'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts index 6d1c436..a4ab4f1 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.module.ts @@ -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'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts index 44c54a9..4605b89 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../../../prisma/prisma.service', () => ({ +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {}, })); import { investmentResourceMeta } from '../investment-resource.mapper'; diff --git a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts index 1e7e5c0..655db21 100644 --- a/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts +++ b/src/modules/investments/brokerage-accounts/brokerage-accounts.service.ts @@ -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'; diff --git a/src/modules/investments/income/income-analytics.controller.ts b/src/modules/investments/income/income-analytics.controller.ts index d72f624..973b593 100644 --- a/src/modules/investments/income/income-analytics.controller.ts +++ b/src/modules/investments/income/income-analytics.controller.ts @@ -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', ); } } diff --git a/src/modules/investments/income/income-analytics.dto.ts b/src/modules/investments/income/income-analytics.dto.ts index a0735f4..9ab09c5 100644 --- a/src/modules/investments/income/income-analytics.dto.ts +++ b/src/modules/investments/income/income-analytics.dto.ts @@ -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: diff --git a/src/modules/investments/income/income-analytics.service.spec.ts b/src/modules/investments/income/income-analytics.service.spec.ts index 320befc..72052e4 100644 --- a/src/modules/investments/income/income-analytics.service.spec.ts +++ b/src/modules/investments/income/income-analytics.service.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../../../prisma/prisma.service', () => ({ +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {}, })); jest.mock('../operations/application/brokerage-operations.service', () => ({ diff --git a/src/modules/investments/income/income-analytics.service.ts b/src/modules/investments/income/income-analytics.service.ts index 0e4f4bd..5eb9734 100644 --- a/src/modules/investments/income/income-analytics.service.ts +++ b/src/modules/investments/income/income-analytics.service.ts @@ -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 { 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 { const plan = planIncomePeriod(from, to); const [current] = await this.operations.collectRanges( userId, accountId, () => [plan.selected], - observe, ); const dependencies: IncomeDependencyDto[] = [ { ...current.meta, role: 'selected' }, diff --git a/src/modules/investments/income/income-period.ts b/src/modules/investments/income/income-period.ts index 43d8bcf..8c39280 100644 --- a/src/modules/investments/income/income-period.ts +++ b/src/modules/investments/income/income-period.ts @@ -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, diff --git a/src/modules/investments/instruments/application/candle.service.spec.ts b/src/modules/investments/instruments/application/candle.service.spec.ts index 0660470..96e7d3a 100644 --- a/src/modules/investments/instruments/application/candle.service.spec.ts +++ b/src/modules/investments/instruments/application/candle.service.spec.ts @@ -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 {}, })); diff --git a/src/modules/investments/instruments/application/candle.service.ts b/src/modules/investments/instruments/application/candle.service.ts index 489c4e4..bbe5275 100644 --- a/src/modules/investments/instruments/application/candle.service.ts +++ b/src/modules/investments/instruments/application/candle.service.ts @@ -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)); diff --git a/src/modules/investments/instruments/application/catalog-discovery.service.ts b/src/modules/investments/instruments/application/catalog-discovery.service.ts index bce90ce..4ea5e5e 100644 --- a/src/modules/investments/instruments/application/catalog-discovery.service.ts +++ b/src/modules/investments/instruments/application/catalog-discovery.service.ts @@ -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) { diff --git a/src/modules/investments/instruments/application/catalog-enrichment.service.ts b/src/modules/investments/instruments/application/catalog-enrichment.service.ts index 1bfa0ae..e8f6f95 100644 --- a/src/modules/investments/instruments/application/catalog-enrichment.service.ts +++ b/src/modules/investments/instruments/application/catalog-enrichment.service.ts @@ -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( @@ -110,6 +91,7 @@ export class CatalogEnrichmentService { tickers?: Record; to: string; }, + refreshBefore?: Date, ) { const { revision } = await this.access.local(userId); const inputs = sourceInputs(instrumentType, uids, kinds, range); @@ -123,6 +105,7 @@ export class CatalogEnrichmentService { false, kinds, range, + refreshBefore, ); } const work = inputs.length @@ -175,6 +158,7 @@ export class CatalogEnrichmentService { tickers?: Record; to: string; }, + refreshBefore?: Date, ) { const inputs = sourceInputs(instrumentType, uids, kinds, range); const reservation = this.reservations.then(async () => { @@ -189,7 +173,9 @@ export class CatalogEnrichmentService { ); const policy = sourcePolicy(input.kind); const needsRefresh = force || !row?.fetchedAt || - (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())); + (policy === 'quote' && tbankSourceNeedsRefresh('quote', row.fetchedAt, new Date())) || + // A walk reads a saved calendar once: later accounts see the new fetchedAt. + (policy === 'calendar' && refreshBefore !== undefined && row.fetchedAt < refreshBefore); if (!needsRefresh) { continue; } @@ -236,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' @@ -268,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]; @@ -314,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, }); @@ -332,169 +314,6 @@ export class CatalogEnrichmentService { }; } - // eslint-disable-next-line sonarjs/cognitive-complexity -- Source loading intentionally dispatches by persisted resource kind. - private async load( - input: CatalogSource, - token: string, - attempt: TbankSyncAttempt, - ): Promise { - 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 { @@ -532,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; @@ -634,24 +453,6 @@ function sourceInputs( })), ); } -const previousCloseLookbackDays = 20; -const previousCloseLookbackMs = - previousCloseLookbackDays * MILLISECONDS_PER_DAY; -const payoutHistoryYears = 5; -const payoutFutureYears = 2; -export type CatalogPayload = { - bondEvents?: MoexBondEvent[]; - candle?: null | TbankCandle; - coupons?: TbankCoupon[]; - creditRating?: Awaited>; - description?: null | string; - dividends?: TbankDividend[]; - effectiveYield?: Awaited>; - fundamentals?: null | TbankFundamentals; - instrument?: null | TbankBond | TbankShare; - price?: null | TbankLastPrice; - tradingStatus?: null | string; -}; function sourcePolicy(kind: CatalogSource['kind']) { if ( @@ -665,6 +466,7 @@ function sourcePolicy(kind: CatalogSource['kind']) { ) { return 'quote'; } + if (['bond-events', 'dividends'].includes(kind)) return 'calendar'; return 'stable'; } function producerKey(input: CatalogSource) { @@ -673,6 +475,3 @@ function producerKey(input: CatalogSource) { ? `bond-metadata:${input.uid}` : catalogScope(input); } - -const quotationScale = 9; -const quotationUnits = 1_000_000_000n; diff --git a/src/modules/investments/instruments/application/catalog-selection.ts b/src/modules/investments/instruments/application/catalog-selection.ts index 471dbe4..93dbdb6 100644 --- a/src/modules/investments/instruments/application/catalog-selection.ts +++ b/src/modules/investments/instruments/application/catalog-selection.ts @@ -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; diff --git a/src/modules/investments/instruments/application/catalog-source.loader.ts b/src/modules/investments/instruments/application/catalog-source.loader.ts new file mode 100644 index 0000000..f9007db --- /dev/null +++ b/src/modules/investments/instruments/application/catalog-source.loader.ts @@ -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>; + description?: null | string; + dividends?: TbankDividend[]; + effectiveYield?: Awaited>; + 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 { + 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'); + } +} diff --git a/src/modules/investments/instruments/application/instrument-market.ts b/src/modules/investments/instruments/application/instrument-market.ts index 53f9c52..cbc01ca 100644 --- a/src/modules/investments/instruments/application/instrument-market.ts +++ b/src/modules/investments/instruments/application/instrument-market.ts @@ -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; diff --git a/src/modules/investments/instruments/application/instrument-position.ts b/src/modules/investments/instruments/application/instrument-position.ts index 6bbc2cb..26b5569 100644 --- a/src/modules/investments/instruments/application/instrument-position.ts +++ b/src/modules/investments/instruments/application/instrument-position.ts @@ -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, diff --git a/src/modules/investments/instruments/application/public-instrument.spec.ts b/src/modules/investments/instruments/application/public-instrument.spec.ts index 063ea43..0e904a8 100644 --- a/src/modules/investments/instruments/application/public-instrument.spec.ts +++ b/src/modules/investments/instruments/application/public-instrument.spec.ts @@ -1,7 +1,7 @@ import type { TbankBond, TbankShare, -} from '../../../../integrations/tbank/tbank.api'; +} from '#src/integrations/tbank/tbank.api'; import { publicInstrument } from './public-instrument'; diff --git a/src/modules/investments/instruments/application/public-instrument.ts b/src/modules/investments/instruments/application/public-instrument.ts index c35a23b..9b29084 100644 --- a/src/modules/investments/instruments/application/public-instrument.ts +++ b/src/modules/investments/instruments/application/public-instrument.ts @@ -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; diff --git a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts index dbfcee5..6c4cc15 100644 --- a/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-catalog.service.ts @@ -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 { 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 { - 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 { const result = await this.discovery.byTicker( userId, 'bond', ticker, - observe, ); return { ...result, diff --git a/src/modules/investments/instruments/bonds/application/bond-income.service.ts b/src/modules/investments/instruments/bonds/application/bond-income.service.ts index 675513a..5e71d90 100644 --- a/src/modules/investments/instruments/bonds/application/bond-income.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-income.service.ts @@ -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 { 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 { 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 }, ); diff --git a/src/modules/investments/instruments/bonds/application/bond-market.service.ts b/src/modules/investments/instruments/bonds/application/bond-market.service.ts index 5902ea8..e7919d6 100644 --- a/src/modules/investments/instruments/bonds/application/bond-market.service.ts +++ b/src/modules/investments/instruments/bonds/application/bond-market.service.ts @@ -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 { 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, ); } } diff --git a/src/modules/investments/instruments/bonds/application/bond.spec.ts b/src/modules/investments/instruments/bonds/application/bond.spec.ts index fed812e..8c1bd40 100644 --- a/src/modules/investments/instruments/bonds/application/bond.spec.ts +++ b/src/modules/investments/instruments/bonds/application/bond.spec.ts @@ -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) }); diff --git a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts index fd115e7..66ca80e 100644 --- a/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts +++ b/src/modules/investments/instruments/bonds/infrastructure/bond-money.mapper.ts @@ -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(); diff --git a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts index 1136dca..f5f9a6b 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.controller.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.controller.ts @@ -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); } } diff --git a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts index a7624c1..27f3e59 100644 --- a/src/modules/investments/instruments/bonds/presentation/bond.dto.ts +++ b/src/modules/investments/instruments/bonds/presentation/bond.dto.ts @@ -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, diff --git a/src/modules/investments/instruments/domain/candle-coverage.ts b/src/modules/investments/instruments/domain/candle-coverage.ts index ef2dad1..dafa484 100644 --- a/src/modules/investments/instruments/domain/candle-coverage.ts +++ b/src/modules/investments/instruments/domain/candle-coverage.ts @@ -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; diff --git a/src/modules/investments/instruments/infrastructure/candle.repository.ts b/src/modules/investments/instruments/infrastructure/candle.repository.ts index d01ec5b..bd1ebcd 100644 --- a/src/modules/investments/instruments/infrastructure/candle.repository.ts +++ b/src/modules/investments/instruments/infrastructure/candle.repository.ts @@ -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 }, }); diff --git a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts index 23cbc0e..5a17718 100644 --- a/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts +++ b/src/modules/investments/instruments/infrastructure/catalog-discovery.repository.ts @@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; import { isDeepStrictEqual } from 'node:util'; -import type { Prisma } from '../../../../../generated/prisma/client'; -import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; - -import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; -import { PrismaService } from '../../../../prisma/prisma.service'; +import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; +import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim'; +import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api'; +import { PrismaService } from '#src/prisma/prisma.service'; +import { toJson } from '#src/prisma/to-json'; const UNCHANGED_REVISION_INCREMENT = 0; const REVISION_INCREMENT = 1; @@ -159,27 +159,19 @@ export class CatalogDiscoveryRepository { initiator: TbankSyncAttempt = attempt, ) { return this.prisma.$transaction(async (tx) => { - const origin = await tx.tbankSyncWork.updateMany({ - where: { - ...initiator, - state: { in: ['running', 'complete'] }, - connection: { invalidToken: false }, - }, - data: { progress: {} }, + const origin = await claimSyncWork( + tx, + initiator, + { progress: {} }, + { state: { in: ['running', 'complete'] } }, + ); + if (!origin) return false; + const claimed = await claimSyncWork(tx, attempt, { + state: 'complete', + progress: {}, }); - if (!origin.count) return false; - const claimed = await tx.tbankSyncWork.updateMany({ - where: { - ...attempt, - state: 'running', - connection: { invalidToken: false }, - }, - data: { state: 'complete', progress: {} }, - }); - if (!claimed.count) return false; - const payload = JSON.parse( - JSON.stringify(value), - ) as Prisma.InputJsonObject; + if (!claimed) return false; + const payload = toJson(value); const identity = { provider, uid: input.uid }; await tx.tbankInstrument.upsert({ where: { provider_uid: identity }, @@ -202,12 +194,10 @@ export class CatalogDiscoveryRepository { const previous = await tx.tbankInstrument.findUniqueOrThrow({ where: { provider_uid: identity }, }); - const metadata = JSON.parse( - JSON.stringify({ - ...instrument, - instrumentType: input.instrumentType, - }), - ) as Prisma.InputJsonObject; + const metadata = toJson({ + ...instrument, + instrumentType: input.instrumentType, + }); await tx.tbankInstrument.update({ where: { provider_uid: identity }, data: { @@ -284,20 +274,13 @@ export class CatalogDiscoveryRepository { active: ReadonlySet, ) { return this.prisma.$transaction(async (tx) => { - const current = await tx.tbankConnection.updateMany({ - where: { ...owner, invalidToken: false }, - data: { membershipRevision: { increment: 0 } }, - }); - if (!current.count) return null; + const current = await lockConnectionGeneration(tx, owner); + if (!current) return null; const scope = { ...owner, scopeKey: catalogScope(input) }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: scope }, }); - if ( - previous?.state === 'running' && - previous.processRunId === runId && - active.has(previous.attemptId) - ) { + if (isJoinableRunningAttempt(previous, runId, active)) { return { work: previous, launch: false }; } const data = { @@ -345,34 +328,28 @@ export class CatalogDiscoveryRepository { now: Date, ) { return this.prisma.$transaction(async (tx) => { - const claimed = await tx.tbankSyncWork.updateMany({ - where: { - ...attempt, - state: 'running', - connection: { invalidToken: false }, - }, - data: { state: 'complete', progress: {} }, + const claimed = await claimSyncWork(tx, attempt, { + state: 'complete', + progress: {}, }); - if (!claimed.count) return false; + if (!claimed) return false; for (const instrument of instruments) { // Discovery can create a minimal identity, but cannot overwrite verified metadata. const identity = { provider, uid: instrument.uid }; - const payload = JSON.parse( - JSON.stringify({ - uid: instrument.uid, - figi: instrument.figi, - ticker: instrument.ticker, - name: instrument.name, - isin: instrument.isin, - classCode: instrument.classCode, - instrumentKind: instrument.instrumentKind, - apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, - currency: instrument.currency, - brand: instrument.brand - ? { logoName: instrument.brand.logoName } - : undefined, - }), - ) as Prisma.InputJsonObject; + const payload = toJson({ + uid: instrument.uid, + figi: instrument.figi, + ticker: instrument.ticker, + name: instrument.name, + isin: instrument.isin, + classCode: instrument.classCode, + instrumentKind: instrument.instrumentKind, + apiTradeAvailableFlag: instrument.apiTradeAvailableFlag, + currency: instrument.currency, + brand: instrument.brand + ? { logoName: instrument.brand.logoName } + : undefined, + }); await tx.tbankInstrument.upsert({ where: { provider_uid: identity }, update: {}, @@ -465,6 +442,7 @@ export class CatalogDiscoveryRepository { finish(attempt: TbankSyncAttempt, success: boolean) { return this.prisma.$transaction(async (tx) => { + // Fails sibling attempts whose connection was invalidated mid-flight. await tx.tbankSyncWork.updateMany({ where: { ...attempt, @@ -473,16 +451,9 @@ export class CatalogDiscoveryRepository { }, data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken }, }); - await tx.tbankSyncWork.updateMany({ - where: { - ...attempt, - state: 'running', - connection: { invalidToken: false }, - }, - data: { - state: success ? 'complete' : 'failed', - errorCode: success ? null : TbankErrorCode.Unavailable, - }, + await claimSyncWork(tx, attempt, { + state: success ? 'complete' : 'failed', + errorCode: success ? null : TbankErrorCode.Unavailable, }); }); } diff --git a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts index 08942ea..27eb641 100644 --- a/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts +++ b/src/modules/investments/instruments/infrastructure/tbank-candle.mapper.ts @@ -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 { diff --git a/src/modules/investments/instruments/instrument-sources.module.ts b/src/modules/investments/instruments/instrument-sources.module.ts index 23e20cf..5545983 100644 --- a/src/modules/investments/instruments/instrument-sources.module.ts +++ b/src/modules/investments/instruments/instrument-sources.module.ts @@ -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, ], diff --git a/src/modules/investments/instruments/instruments.module.ts b/src/modules/investments/instruments/instruments.module.ts index c0b5a0c..f983754 100644 --- a/src/modules/investments/instruments/instruments.module.ts +++ b/src/modules/investments/instruments/instruments.module.ts @@ -1,7 +1,7 @@ import { Module } from '@nestjs/common'; -import { TbankAccessService } from '../../../integrations/tbank/tbank-access.service'; -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'; @@ -21,7 +21,6 @@ import { StockController } from './stocks/presentation/stock.controller'; controllers: [StockController, BondController], exports: [StockIncomeService, BondIncomeService], providers: [ - { provide: 'TBANK_ACCESS', useExisting: TbankAccessService }, BondIncomeService, BondMarketService, GetStockCompanyUseCase, diff --git a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts index fcc3560..f5e5e74 100644 --- a/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts +++ b/src/modules/investments/instruments/stocks/application/get-stock-company.use-case.ts @@ -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 { const result = await this.sources.section( userId, 'share', uid, 'company', - observe, ); return { data: result.values.instrument diff --git a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts index bb3762a..e5d65e2 100644 --- a/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-catalog.service.ts @@ -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 { 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 { - 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 { const result = await this.discovery.byTicker( userId, 'share', ticker, - observe, ); return { ...result, diff --git a/src/modules/investments/instruments/stocks/application/stock-income.service.ts b/src/modules/investments/instruments/stocks/application/stock-income.service.ts index 18463be..96986bd 100644 --- a/src/modules/investments/instruments/stocks/application/stock-income.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-income.service.ts @@ -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 { 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 }, ); diff --git a/src/modules/investments/instruments/stocks/application/stock-market.service.ts b/src/modules/investments/instruments/stocks/application/stock-market.service.ts index e03ad7a..468904d 100644 --- a/src/modules/investments/instruments/stocks/application/stock-market.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-market.service.ts @@ -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 { 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, ); } } diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts index 34b3323..9604936 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.spec.ts @@ -1,6 +1,10 @@ +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'; @@ -36,7 +40,7 @@ describe('StockScreenerService', () => { client.getAllShares.mockResolvedValue( Array.from({ length: pageSize + 1 }, (_, index) => share(index)), ); - service = new StockScreenerService(access, client as never, logger as never); + service = new StockScreenerService(access as never, client as never, logger as never); }); it('stores the full selected catalog and returns stable non-overlapping pages', async () => { diff --git a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts index e069ff2..a5ab318 100644 --- a/src/modules/investments/instruments/stocks/application/stock-screener.service.ts +++ b/src/modules/investments/instruments/stocks/application/stock-screener.service.ts @@ -1,16 +1,17 @@ import { BadRequestException, - Inject, Injectable, NotFoundException, ServiceUnavailableException, } 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 { TbankClient } from '../../../../../integrations/tbank/tbank.client'; import { selectStockScreenerInstruments, type StockScreenerConditions, @@ -47,17 +48,13 @@ type StockScreenerPage = { searchId: string; }; type Snapshot = { asOf: string; expiresAt: number; items: StockScreenerItem[]; revision: string; userId: string }; -type TbankAccess = { - local(userId: string): Promise<{ revision: string }>; - online(userId: string, run: (connection: { revision: string; token: string }) => Promise): Promise; -}; @Injectable() export class StockScreenerService { private readonly snapshots = new Map(); constructor( - @Inject('TBANK_ACCESS') private readonly access: TbankAccess, + private readonly access: TbankAccessService, private readonly client: TbankClient, private readonly logger: StructuredLogger, ) {} diff --git a/src/modules/investments/instruments/stocks/application/stock.spec.ts b/src/modules/investments/instruments/stocks/application/stock.spec.ts index 8aa115c..e1b58fa 100644 --- a/src/modules/investments/instruments/stocks/application/stock.spec.ts +++ b/src/modules/investments/instruments/stocks/application/stock.spec.ts @@ -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) }); diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts index 4271a6e..f6b5e06 100644 --- a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.spec.ts @@ -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'; diff --git a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts index d6a659d..2287785 100644 --- a/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts +++ b/src/modules/investments/instruments/stocks/domain/stock-screener-selection.rules.ts @@ -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; diff --git a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts index 3c5e11b..f5286d5 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.controller.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.controller.ts @@ -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', ); } @@ -91,6 +88,7 @@ export class StockController { @Body(new ValidationPipe({ forbidNonWhitelisted: true, forbidUnknownValues: true, whitelist: true })) criteria: StockScreenerCriteriaDto, ) { + // The global whitelist pipe strips unknown keys before this param pipe runs, so only the raw body can reject them. const allowed = new Set(['countryOfRisk', 'currency', 'maxLot', 'query', 'realExchange', 'sector', 'shareType']); if (Object.keys(request.body as object).some((key) => !allowed.has(key))) { throw new BadRequestException(); @@ -114,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') @@ -124,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') @@ -140,7 +136,6 @@ export class StockController { request.user.sub, uid, query.period, - query.sync === 'observe', ); } @@ -158,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', ); } @@ -172,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', ); } } diff --git a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts index 14b0e60..10b2eef 100644 --- a/src/modules/investments/instruments/stocks/presentation/stock.dto.ts +++ b/src/modules/investments/instruments/stocks/presentation/stock.dto.ts @@ -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', diff --git a/src/modules/investments/investment-resource.dto.ts b/src/modules/investments/investment-resource.dto.ts index ec666f6..4031d23 100644 --- a/src/modules/investments/investment-resource.dto.ts +++ b/src/modules/investments/investment-resource.dto.ts @@ -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'; diff --git a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts index bc0a98b..9ae268a 100644 --- a/src/modules/investments/operations/application/brokerage-operations-sync.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations-sync.service.ts @@ -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, diff --git a/src/modules/investments/operations/application/brokerage-operations.service.ts b/src/modules/investments/operations/application/brokerage-operations.service.ts index fcabee2..c83fbc9 100644 --- a/src/modules/investments/operations/application/brokerage-operations.service.ts +++ b/src/modules/investments/operations/application/brokerage-operations.service.ts @@ -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('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('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; - 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() }; -} diff --git a/src/modules/investments/operations/application/local-operations-cursor.codec.spec.ts b/src/modules/investments/operations/application/local-operations-cursor.codec.spec.ts new file mode 100644 index 0000000..02c6c8f --- /dev/null +++ b/src/modules/investments/operations/application/local-operations-cursor.codec.spec.ts @@ -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(); + }); +}); diff --git a/src/modules/investments/operations/application/local-operations-cursor.codec.ts b/src/modules/investments/operations/application/local-operations-cursor.codec.ts new file mode 100644 index 0000000..9445bcf --- /dev/null +++ b/src/modules/investments/operations/application/local-operations-cursor.codec.ts @@ -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; + 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('JWT_SECRET'); + } +} diff --git a/src/modules/investments/operations/domain/operations-coverage.ts b/src/modules/investments/operations/domain/operations-coverage.ts new file mode 100644 index 0000000..0029f7d --- /dev/null +++ b/src/modules/investments/operations/domain/operations-coverage.ts @@ -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, + 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, + 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() }; +} diff --git a/src/modules/investments/operations/domain/operations-interval-codec.ts b/src/modules/investments/operations/domain/operations-interval-codec.ts new file mode 100644 index 0000000..edf8b61 --- /dev/null +++ b/src/modules/investments/operations/domain/operations-interval-codec.ts @@ -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), + }; +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts index df9cf0b..27fcce2 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operation.mapper.ts @@ -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'; diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations-read.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations-read.repository.ts new file mode 100644 index 0000000..e0dfadf --- /dev/null +++ b/src/modules/investments/operations/infrastructure/brokerage-operations-read.repository.ts @@ -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, + ), + })), + }; + }); + } +} diff --git a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts index 844d1a8..4980b68 100644 --- a/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts +++ b/src/modules/investments/operations/infrastructure/brokerage-operations.repository.ts @@ -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 { - 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; } diff --git a/src/modules/investments/operations/operations.module.ts b/src/modules/investments/operations/operations.module.ts index d62f1ad..fde5bf2 100644 --- a/src/modules/investments/operations/operations.module.ts +++ b/src/modules/investments/operations/operations.module.ts @@ -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], diff --git a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts index c349c7d..3b195e4 100644 --- a/src/modules/investments/operations/presentation/brokerage-operation.dto.ts +++ b/src/modules/investments/operations/presentation/brokerage-operation.dto.ts @@ -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'; diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts index 66196a5..886a905 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../../../../prisma/prisma.service', () => ({ +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); diff --git a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts index 66d1a38..a3156e8 100644 --- a/src/modules/investments/operations/presentation/brokerage-operations.controller.ts +++ b/src/modules/investments/operations/presentation/brokerage-operations.controller.ts @@ -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, diff --git a/src/modules/investments/portfolio/application/forecast.service.ts b/src/modules/investments/portfolio/application/forecast.service.ts index fd0811b..b481ae2 100644 --- a/src/modules/investments/portfolio/application/forecast.service.ts +++ b/src/modules/investments/portfolio/application/forecast.service.ts @@ -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, @@ -20,6 +21,7 @@ export class ForecastService { lastAttempt?: InvestmentResourceMetaDto['lastAttempt']; membershipRevision: number; portfolio: InvestmentResourceMetaDto; + replacesLatest?: boolean; revision: string; scopeKey: string; sources: { @@ -74,7 +76,7 @@ export class ForecastService { .map((dependency) => dependency.sync) .find((sync) => sync.state === 'failed'); return { - data: stored ? filterEvents(stored.payload as T, input.scopeKey) : null, + data: stored ? filterEvents(stored.payload as T, input.scopeKey, stored.scopeKey) : null, meta: investmentResourceMeta({ resourceKey: input.scopeKey, dataRevision: String(stored?.dataRevision ?? MISSING_DATA_REVISION), @@ -95,7 +97,7 @@ export class ForecastService { } } -function filterEvents(payload: T, scopeKey: string): T { +function filterEvents(payload: T, scopeKey: string, savedScopeKey: string): T { if (!scopeKey.startsWith('events/')) return payload; try { const [, from, to] = JSON.parse(scopeKey.slice('events/'.length)) as [string, string, string]; @@ -111,8 +113,10 @@ function filterEvents(payload: T, scopeKey: string): T { const current = totals.get(event.projectedAmount.currency) ?? { value: 0n, scale: 0 }; totals.set(event.projectedAmount.currency, add(current, decimalFromString(event.projectedAmount.value))); } + const [, savedFrom, savedTo] = JSON.parse(savedScopeKey.slice('events/'.length)) as [string, string, string]; return { ...value, + window: { from: savedFrom, to: savedTo }, events, incomeTotalsByCurrency: [...totals] .sort(([left], [right]) => left.localeCompare(right)) diff --git a/src/modules/investments/portfolio/application/portfolio.service.spec.ts b/src/modules/investments/portfolio/application/portfolio.service.spec.ts index 448e2e6..8b7b0e8 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.spec.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.spec.ts @@ -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'; @@ -218,6 +218,18 @@ describe('persistent portfolio projection', () => { expect(snapshot.structure.currencies[0].totalValue).toBe('121.49'); }); + // Test intent: a corrupted Json payload must fail loudly instead of being spread into the API response. + it('rejects a stored snapshot payload without the persisted shape', async () => { + repository.read.mockResolvedValue({ + membershipAt, + membershipRevision: 1, + accounts: [{ accountId: 'a', name: 'A', type: 'BROKERAGE', openedOn: null, + portfolio: { payload: { positions: 'broken' }, fetchedAt: membershipAt, dataRevision: 1 } }], + work: [], + }); + await expect(service.read('user', 'all')).rejects.toThrow('Corrupted portfolio snapshot payload'); + }); + it('does not turn a missing account snapshot into aggregate zero; summaries stay independent', async () => { const snapshot: PortfolioDto = { lastSuccessfulAt: membershipAt.toISOString(), diff --git a/src/modules/investments/portfolio/application/portfolio.service.ts b/src/modules/investments/portfolio/application/portfolio.service.ts index b001098..b6d298c 100644 --- a/src/modules/investments/portfolio/application/portfolio.service.ts +++ b/src/modules/investments/portfolio/application/portfolio.service.ts @@ -4,8 +4,10 @@ import { } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; -import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; +import type { Prisma } from '#generated/prisma/client'; + +import { StructuredLogger } from '#src/common/logging/structured-logger.service'; +import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; import { InstrumentMetadataService, metadataKey, @@ -15,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, @@ -26,6 +29,7 @@ import { sumMoneyByCurrency } from '../domain/portfolio-totals'; import { portfolioFreshForMs, PortfolioRepository, + portfolioSnapshot, portfolioWorkKey, } from '../infrastructure/portfolio.repository'; import { mapPortfolio } from '../infrastructure/tbank-portfolio.mapper'; @@ -88,18 +92,7 @@ export class PortfolioService { type: account.type, openedOn: account.openedOn, snapshot: account.portfolio - ? { - ...(account.portfolio.payload as unknown as PortfolioDto), - positions: (account.portfolio.payload as unknown as PortfolioDto).positions.map((position) => ({ - ...position, - sector: position.sector ?? null, - issuer: position.issuer ?? null, - })), - lastSuccessfulAt: account.portfolio.fetchedAt.toISOString(), - isStale: - Date.now() - account.portfolio.fetchedAt.valueOf() >= - portfolioFreshForMs, - } + ? savedSnapshot(account.portfolio) : null, meta: investmentResourceMeta({ resourceKey: `portfolio/${encodeURIComponent(account.accountId)}`, @@ -326,3 +319,17 @@ export class PortfolioService { return mapPortfolio(upstream, money, savedMetadata, lastSuccessfulAt, classifications); } } + +function savedSnapshot(saved: { fetchedAt: Date; payload: Prisma.JsonValue }): PortfolioDto { + const snapshot = portfolioSnapshot(saved.payload); + return { + ...snapshot, + positions: snapshot.positions.map((position) => ({ + ...position, + sector: position.sector ?? null, + issuer: position.issuer ?? null, + })), + lastSuccessfulAt: saved.fetchedAt.toISOString(), + isStale: Date.now() - saved.fetchedAt.valueOf() >= portfolioFreshForMs, + }; +} diff --git a/src/modules/investments/portfolio/application/position-classification.service.spec.ts b/src/modules/investments/portfolio/application/position-classification.service.spec.ts index 4b26d44..3b4ac6b 100644 --- a/src/modules/investments/portfolio/application/position-classification.service.spec.ts +++ b/src/modules/investments/portfolio/application/position-classification.service.spec.ts @@ -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', () => { diff --git a/src/modules/investments/portfolio/application/position-classification.service.ts b/src/modules/investments/portfolio/application/position-classification.service.ts index 1832f6a..828a87c 100644 --- a/src/modules/investments/portfolio/application/position-classification.service.ts +++ b/src/modules/investments/portfolio/application/position-classification.service.ts @@ -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 }; diff --git a/src/modules/investments/portfolio/domain/portfolio-structure.ts b/src/modules/investments/portfolio/domain/portfolio-structure.ts index 7498ff6..9b92249 100644 --- a/src/modules/investments/portfolio/domain/portfolio-structure.ts +++ b/src/modules/investments/portfolio/domain/portfolio-structure.ts @@ -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; diff --git a/src/modules/investments/portfolio/domain/portfolio-totals.ts b/src/modules/investments/portfolio/domain/portfolio-totals.ts index da81680..25cdd2a 100644 --- a/src/modules/investments/portfolio/domain/portfolio-totals.ts +++ b/src/modules/investments/portfolio/domain/portfolio-totals.ts @@ -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>(); diff --git a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts index ca6f52d..c8066c3 100644 --- a/src/modules/investments/portfolio/infrastructure/forecast.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/forecast.repository.ts @@ -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; @@ -26,6 +27,7 @@ export class ForecastRepository { membershipRevision: number; portfolioProjection: string; portfolios: { accountId: string; dataRevision: number }[]; + replacesLatest?: boolean; revision: string; scopeKey: string; sources: ForecastSourceVersion[]; @@ -51,32 +53,30 @@ export class ForecastRepository { const previous = await tx.tbankForecast.findUnique({ where: { userId_revision_scopeKey: key }, }); + // An events read serves the account's latest complete forecast whatever + // the requested window; other scopes keep their exact-key row. const requestedWindow = eventsWindow(input.scopeKey); - const saved = previous ?? (requestedWindow - ? ( - await tx.tbankForecast.findMany({ - where: { - ...owner, - scopeKey: { startsWith: 'events/' }, - membershipRevision: input.membershipRevision, + const latest = requestedWindow + ? await tx.tbankForecast.findFirst({ + where: { + ...owner, + // scopeKey is `events/[accountId,from,to]` JSON: pin the account. + scopeKey: { + startsWith: `events/[${JSON.stringify(requestedWindow.accountId)},`, }, - }) - ).find((forecast) => { - const window = eventsWindow(forecast.scopeKey); - return ( - window && - window.accountId === requestedWindow.accountId && - window.from <= requestedWindow.to && - requestedWindow.from <= window.to - ); + membershipRevision: input.membershipRevision, + }, + orderBy: [{ calculatedAt: 'desc' }, { scopeKey: 'asc' }], }) - : null); + : previous; const fallback = - saved?.membershipRevision === input.membershipRevision - ? saved - : null; + latest?.membershipRevision === input.membershipRevision ? latest : null; if (!input.candidate) return fallback; - if (!previous && fallback) return fallback; + // Only the default window may replace the latest forecast; an explicit + // range is served from it and publishes only when nothing is saved. + if (!input.replacesLatest && fallback && fallback.scopeKey !== input.scopeKey) { + return fallback; + } const portfolios = await tx.tbankPortfolio.findMany({ where: { ...owner, @@ -143,16 +143,14 @@ 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 && + fallback?.scopeKey === input.scopeKey && isDeepStrictEqual(fallback.dependencyRevisions, revisions) && isDeepStrictEqual(fallback.sourceTimestamps, timestamps) ) { @@ -160,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(), diff --git a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts index 2d5cfd3..14f9448 100644 --- a/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/portfolio.repository.ts @@ -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({ @@ -200,3 +193,16 @@ export class PortfolioRepository { export function portfolioWorkKey(accountId: string): string { return `portfolio:${JSON.stringify(accountId)}`; } + +// Json payload is written only by publish(); a shape check turns corruption into an explicit 500. +export function portfolioSnapshot(value: Prisma.JsonValue): PortfolioDto { + const snapshot = value as null | Partial; + if ( + !Array.isArray(snapshot?.positions) || + !Array.isArray(snapshot.totalsByCurrency) || + !Array.isArray(snapshot.cashBalances) + ) { + throw new Error('Corrupted portfolio snapshot payload'); + } + return snapshot as PortfolioDto; +} diff --git a/src/modules/investments/portfolio/infrastructure/position-classification.repository.ts b/src/modules/investments/portfolio/infrastructure/position-classification.repository.ts index 937cc18..4595455 100644 --- a/src/modules/investments/portfolio/infrastructure/position-classification.repository.ts +++ b/src/modules/investments/portfolio/infrastructure/position-classification.repository.ts @@ -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 { diff --git a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts index 47d3ae6..79e171f 100644 --- a/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts +++ b/src/modules/investments/portfolio/infrastructure/tbank-portfolio.mapper.ts @@ -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 = { diff --git a/src/modules/investments/portfolio/portfolio.module.ts b/src/modules/investments/portfolio/portfolio.module.ts index 05c3a6e..9bb6b29 100644 --- a/src/modules/investments/portfolio/portfolio.module.ts +++ b/src/modules/investments/portfolio/portfolio.module.ts @@ -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'; diff --git a/src/modules/investments/portfolio/presentation/portfolio.controller.ts b/src/modules/investments/portfolio/presentation/portfolio.controller.ts index 3b4919a..323e166 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.controller.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.controller.ts @@ -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'; diff --git a/src/modules/investments/portfolio/presentation/portfolio.dto.ts b/src/modules/investments/portfolio/presentation/portfolio.dto.ts index fdd6bd9..7527c7f 100644 --- a/src/modules/investments/portfolio/presentation/portfolio.dto.ts +++ b/src/modules/investments/portfolio/presentation/portfolio.dto.ts @@ -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'; diff --git a/src/modules/investments/tbank-connection/tbank-connection.controller.ts b/src/modules/investments/tbank-connection/tbank-connection.controller.ts index 6d94617..90add97 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.controller.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.controller.ts @@ -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, diff --git a/src/modules/investments/tbank-connection/tbank-connection.module.ts b/src/modules/investments/tbank-connection/tbank-connection.module.ts index 54fcc35..12357b9 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.module.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.module.ts @@ -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 { TbankSyncModule } from '../tbank-sync/tbank-sync.module'; import { TbankConnectionController } from './tbank-connection.controller'; diff --git a/src/modules/investments/tbank-connection/tbank-connection.service.ts b/src/modules/investments/tbank-connection/tbank-connection.service.ts index ff2b2de..766d5c8 100644 --- a/src/modules/investments/tbank-connection/tbank-connection.service.ts +++ b/src/modules/investments/tbank-connection/tbank-connection.service.ts @@ -1,12 +1,12 @@ import { BadRequestException, Inject, Injectable } from '@nestjs/common'; -import { HttpErrorCode } from '../../../common/error-code'; +import { HttpErrorCode } from '#src/common/error-code'; import { supportedTbankAccounts, TbankClient, TbankTokenService, -} from '../../../integrations/tbank/tbank.api'; -import { PrismaService } from '../../../prisma/prisma.service'; +} from '#src/integrations/tbank/tbank.api'; +import { PrismaService } from '#src/prisma/prisma.service'; type TbankTokenValidator = Pick; diff --git a/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts b/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts index 1ac7b2a..1b72823 100644 --- a/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts +++ b/src/modules/investments/tbank-sync/tbank-hourly.scheduler.ts @@ -1,17 +1,18 @@ import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { randomUUID } from 'node:crypto'; -import { StructuredLogger } from '../../../common/logging/structured-logger.service'; -import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; +import { StructuredLogger } from '#src/common/logging/structured-logger.service'; +import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code'; import { TbankAccessService, TbankMembershipService, type TbankSyncAttempt, -} from '../../../integrations/tbank/tbank.api'; -import { PrismaService } from '../../../prisma/prisma.service'; +} from '#src/integrations/tbank/tbank.api'; +import { PrismaService } from '#src/prisma/prisma.service'; + import { BrokerageOperationsSyncService } from '../operations/operations.api'; import { PortfolioService } from '../portfolio/portfolio.api'; -import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service'; +import { UpcomingEventsService } from '../upcoming-events/upcoming-events.api'; import { HOURLY_CAUSE_INTERRUPTED, type HourlyCause, @@ -268,6 +269,7 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit { account.accountId, dispatch, dispatchProblems, + walkStartedAt, ); } } @@ -340,9 +342,12 @@ export class TbankHourlyScheduler implements OnModuleDestroy, OnModuleInit { accountId: string, dispatch: DispatchStep, dispatchProblems: HourlySection[], + walkStartedAt: Date, ) { try { - const ids = await this.withBound(this.events.refresh(userId, accountId)); + const ids = await this.withBound( + this.events.refresh(userId, accountId, {}, walkStartedAt), + ); if (ids !== BOUND_EXPIRED) { for (const id of ids) { await dispatch({ accountId, id, section: 'events' }); diff --git a/src/modules/investments/tbank-sync/tbank-screen.ts b/src/modules/investments/tbank-sync/tbank-screen.ts index ca228f0..0bb14b1 100644 --- a/src/modules/investments/tbank-sync/tbank-screen.ts +++ b/src/modules/investments/tbank-sync/tbank-screen.ts @@ -1,8 +1,9 @@ import { BadRequestException } from '@nestjs/common'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; + import type { InvestmentScreenDto } from './tbank-sync.dto'; -import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; import { MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query'; // Reject fields from other screens before reserving any source work. diff --git a/src/modules/investments/tbank-sync/tbank-sync.controller.ts b/src/modules/investments/tbank-sync/tbank-sync.controller.ts deleted file mode 100644 index c6a62ba..0000000 --- a/src/modules/investments/tbank-sync/tbank-sync.controller.ts +++ /dev/null @@ -1,42 +0,0 @@ -import type { Request } from 'express'; - -import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; -import { - ApiBearerAuth, - ApiExtraModels, - ApiOkResponse, - ApiTags, -} from '@nestjs/swagger'; - -import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; -import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; -import { - EventsInvestmentScreenDto, - IncomeInvestmentScreenDto, - TbankSyncStatusDto, - TimestampInvestmentScreenDto, -} from './tbank-sync.dto'; -import { TbankSyncService } from './tbank-sync.service'; - -@ApiTags('tbank-sync') -@ApiExtraModels( - EventsInvestmentScreenDto, - IncomeInvestmentScreenDto, - TimestampInvestmentScreenDto, - TbankSyncStatusDto, -) -@ApiProblemResponses() -@ApiBearerAuth() -@UseGuards(JwtAuthGuard) -@Controller('tbank-sync') -export class TbankSyncController { - constructor(private readonly sync: TbankSyncService) {} - @Get(':id') - @ApiOkResponse({ type: TbankSyncStatusDto }) - status( - @Req() request: Request & { user: AccessPayload }, - @Param('id') id: string, - ) { - return this.sync.status(request.user.sub, id); - } -} diff --git a/src/modules/investments/tbank-sync/tbank-sync.dto.ts b/src/modules/investments/tbank-sync/tbank-sync.dto.ts index d3a28f7..0d76ed7 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.dto.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.dto.ts @@ -209,19 +209,6 @@ export const investmentScreenSchema = { }, }; -export class TbankSyncStatusDto { - @ApiProperty({ format: 'uuid' }) declare id: string; - @ApiProperty({ type: Object, ...investmentScreenSchema, nullable: true }) - declare scope: InvestmentScreenDto | null; - - @ApiProperty({ format: 'date-time' }) declare now: string; - @ApiProperty({ enum: ['running', 'succeeded', 'failed', 'cancelled'] }) - declare state: 'cancelled' | 'failed' | 'running' | 'succeeded'; - - @ApiProperty({ type: String, nullable: true }) declare errorCode: - null | string; -} - export class HourlySyncStatusDto { @ApiProperty({ enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'], diff --git a/src/modules/investments/tbank-sync/tbank-sync.module.ts b/src/modules/investments/tbank-sync/tbank-sync.module.ts index 44756fb..4e4cd40 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.module.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.module.ts @@ -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 { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { OperationsModule } from '../operations/operations.module'; @@ -8,8 +9,6 @@ import { PortfolioModule } from '../portfolio/portfolio.module'; import { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module'; import { TbankHourlyScheduler } from './tbank-hourly.scheduler'; import { TbankSyncRepositoryModule } from './tbank-sync-repository.module'; -import { TbankSyncController } from './tbank-sync.controller'; -import { TbankSyncService } from './tbank-sync.service'; @Module({ imports: [ AuthModule, @@ -20,8 +19,7 @@ import { TbankSyncService } from './tbank-sync.service'; UpcomingEventsModule, TbankSyncRepositoryModule, ], - controllers: [TbankSyncController], - providers: [TbankSyncService, TbankHourlyScheduler], + providers: [TbankHourlyScheduler], exports: [TbankHourlyScheduler], }) export class TbankSyncModule {} diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts new file mode 100644 index 0000000..d3938d7 --- /dev/null +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.spec.ts @@ -0,0 +1,71 @@ +jest.mock('#src/prisma/prisma.service', () => ({ + PrismaService: class {}, +})); + +import { + hourlyProgress, + screenProgress, +} from './tbank-sync.repository'; + +describe('screen sync progress', () => { + it('restores a persisted progress unchanged', () => { + const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true }; + expect(screenProgress(progress)).toEqual(progress); + }); + + it('tolerates legacy rows without attempts', () => { + expect(screenProgress({ children: [], dispatched: false })).toEqual({ + children: [], + dispatched: false, + }); + }); + + it('rejects corrupt rows with an explicit error', () => { + expect(() => screenProgress(null)).toThrow('Corrupted screen sync progress'); + expect(() => screenProgress({})).toThrow('Corrupted screen sync progress'); + expect(() => screenProgress({ children: 'x', dispatched: false })).toThrow( + 'Corrupted screen sync progress', + ); + expect(() => screenProgress({ children: [1], dispatched: false })).toThrow( + 'Corrupted screen sync progress', + ); + expect( + () => screenProgress({ children: [], dispatched: 'yes' }), + ).toThrow('Corrupted screen sync progress'); + expect( + () => screenProgress({ children: [], dispatched: false, attempts: { w: 7 } }), + ).toThrow('Corrupted screen sync progress'); + }); +}); + +describe('hourly sync progress', () => { + it('restores a persisted progress unchanged', () => { + const progress = { + attempts: { 'work-1': 'attempt-1' }, + children: [{ id: 'work-1', section: 'portfolio' }], + result: 'partial', + sections: [{ errorCode: null, section: 'events' }], + }; + expect(hourlyProgress(progress)).toEqual(progress); + }); + + it('tolerates legacy rows without optional collections', () => { + expect(hourlyProgress({ sections: [] })).toEqual({ sections: [] }); + // Legacy hourly rows were seeded with an empty progress object. + expect(hourlyProgress({})).toEqual({}); + }); + + it('rejects corrupt rows with an explicit error', () => { + expect(() => hourlyProgress(null)).toThrow('Corrupted hourly sync progress'); + expect(() => hourlyProgress({ sections: 'x' })).toThrow('Corrupted hourly sync progress'); + expect(() => hourlyProgress({ sections: [{ errorCode: 'x', section: 1 }] })).toThrow( + 'Corrupted hourly sync progress', + ); + expect(() => + hourlyProgress({ sections: [], children: [{ id: 'w', section: 5 }] }), + ).toThrow('Corrupted hourly sync progress'); + expect(() => hourlyProgress({ sections: [], attempts: { w: 7 } })).toThrow( + 'Corrupted hourly sync progress', + ); + }); +}); diff --git a/src/modules/investments/tbank-sync/tbank-sync.repository.ts b/src/modules/investments/tbank-sync/tbank-sync.repository.ts index dbeab81..23a305e 100644 --- a/src/modules/investments/tbank-sync/tbank-sync.repository.ts +++ b/src/modules/investments/tbank-sync/tbank-sync.repository.ts @@ -1,18 +1,45 @@ import { Injectable } from '@nestjs/common'; import { 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 type { TbankSyncAttempt } from '#src/integrations/tbank/tbank.api'; + +import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim'; +import { PrismaService } from '#src/prisma/prisma.service'; +import { toJson } from '#src/prisma/to-json'; + import type { InvestmentAttemptDto } from '../investment-resource.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto'; - -import { PrismaService } from '../../../prisma/prisma.service'; -export type ScreenIntent = { now: string; scope: InvestmentScreenDto }; export type ScreenProgress = { attempts?: Record; children: string[]; dispatched: boolean; }; +// Progress columns are written only by this repository; a shape check turns +// corruption into an explicit 500. Legacy rows predate optional fields, so +// only the fields every writer has always written are required. +function validAttemptMap(attempts: unknown): boolean { + return ( + attempts === undefined || + (typeof attempts === 'object' && + attempts !== null && + !Array.isArray(attempts) && + Object.values(attempts).every((id) => typeof id === 'string')) + ); +} + +export function screenProgress(value: Prisma.JsonValue): ScreenProgress { + const progress = value as null | Partial; + if ( + typeof progress?.dispatched !== 'boolean' || + !Array.isArray(progress.children) || + !progress.children.every((child) => typeof child === 'string') || + !validAttemptMap(progress.attempts) + ) { + throw new Error('Corrupted screen sync progress'); + } + return progress as ScreenProgress; +} export type HourlySection = { accountId?: string; errorCode: null | string; @@ -34,6 +61,42 @@ export type HourlyProgress = { result?: 'failed' | 'partial' | 'succeeded'; sections: HourlySection[]; }; +// Same tolerance rule as screenProgress: required fields are the ones every +// hourly writer has always written; optional collections may be absent. +export function hourlyProgress(value: Prisma.JsonValue): HourlyProgress { + const progress: unknown = value; + if (typeof progress !== 'object' || progress === null) { + throw new Error('Corrupted hourly sync progress'); + } + const row = progress as Record; + const validChildren = + row.children === undefined || + (Array.isArray(row.children) && + row.children.every((child) => { + if (typeof child !== 'object' || child === null) return false; + const entry = child as Record; + return typeof entry.id === 'string' && typeof entry.section === 'string'; + })); + const validSections = + row.sections === undefined || + (Array.isArray(row.sections) && + row.sections.every((section) => { + if (typeof section !== 'object' || section === null) return false; + const entry = section as Record; + return ( + typeof entry.section === 'string' && + (typeof entry.errorCode === 'string' || entry.errorCode === null) + ); + })); + if ( + !validSections || + !validAttemptMap(row.attempts) || + !validChildren + ) { + throw new Error('Corrupted hourly sync progress'); + } + return progress as HourlyProgress; +} type HourlyAttempt = { finishedAt: Date | null; sections: HourlySection[]; @@ -79,18 +142,15 @@ export class TbankSyncRepository { runId: string, ) { return this.prisma.$transaction(async (tx) => { - const valid = await tx.tbankConnection.updateMany({ - where: { userId, revision, invalidToken: false }, - data: { membershipRevision: { increment: 0 } }, - }); - if (!valid.count) return null; + const valid = await lockConnectionGeneration(tx, { userId, revision }); + if (!valid) return null; const scopeKey = `screen:${JSON.stringify(scope)}`; const key = { userId, revision, scopeKey }; const previous = await tx.tbankSyncWork.findUnique({ where: { userId_revision_scopeKey: key }, }); if (previous?.state === 'running' && previous.processRunId === runId) { - const progress = previous.progress as ScreenProgress; + const progress = screenProgress(previous.progress); const children = progress.children ?? []; if ( !progress.dispatched || @@ -106,9 +166,7 @@ export class TbankSyncRepository { processRunId: runId, state: 'running', priority: 0, - intent: JSON.parse( - JSON.stringify({ scope, now: new Date().toISOString() }), - ) as Prisma.InputJsonObject, + intent: toJson({ scope, now: new Date().toISOString() }), progress: { children: [], dispatched: false }, errorCode: null, }; @@ -121,30 +179,6 @@ export class TbankSyncRepository { }); } - read(userId: string, revision: string, id: string) { - return this.prisma.$transaction(async (tx) => { - const work = await tx.tbankSyncWork.findFirst({ - where: { userId, revision, id }, - }); - if (!work) return null; - const progress = work.progress as ScreenProgress; - const children = await tx.tbankSyncWork.findMany({ - where: { userId, revision, id: { in: progress.children ?? [] } }, - select: { id: true, attemptId: true, state: true, errorCode: true }, - }); - return { - work, - children: children.map((child) => - progress.attempts?.[child.id] && - progress.attempts[child.id] !== child.attemptId - ? { ...child, state: 'cancelled', errorCode: null } - : child, - ), - progress, - }; - }); - } - async progress( attempt: TbankSyncAttempt, children: string[], @@ -154,7 +188,7 @@ export class TbankSyncRepository { return this.prisma.$transaction(async (tx) => { const previous = await tx.tbankSyncWork.findFirst({ where: attempt }); if (!previous) return { count: 0 }; - const old = previous.progress as ScreenProgress; + const old = screenProgress(previous.progress); const rows = await tx.tbankSyncWork.findMany({ where: { userId: attempt.userId, @@ -167,17 +201,11 @@ export class TbankSyncRepository { ...Object.fromEntries(rows.map((row) => [row.id, row.attemptId])), ...old.attempts, }; - return tx.tbankSyncWork.updateMany({ - where: { - ...attempt, - state: 'running', - connection: { invalidToken: false }, - }, - data: { - progress: { children: [...new Set(children)], attempts, dispatched }, - ...(errorCode ? { state: 'failed', errorCode } : {}), - }, + const claimed = await claimSyncWork(tx, attempt, { + progress: { children: [...new Set(children)], attempts, dispatched }, + ...(errorCode ? { state: 'failed', errorCode } : {}), }); + return { count: claimed }; }); } @@ -226,10 +254,8 @@ export class TbankSyncRepository { processRunId: runId, state: 'running', priority: 0, - intent: JSON.parse( - JSON.stringify({ source: 'hourly' }), - ) as Prisma.InputJsonObject, - progress: { sections: [] } as unknown as Prisma.InputJsonValue, + intent: { source: 'hourly' }, + progress: { sections: [] }, errorCode: null, }; const work = await tx.tbankSyncWork.upsert({ @@ -288,7 +314,7 @@ export class TbankSyncRepository { cause: HourlyCause | null, dispatchProblems: HourlySection[], ) { - const progress = work.progress as HourlyProgress; + const progress = hourlyProgress(work.progress); const children = progress.children ?? []; const rows = children.length ? await tx.tbankSyncWork.findMany({ @@ -352,25 +378,22 @@ export class TbankSyncRepository { select: { progress: true }, }); if (!work) return false; - const progress = work.progress as HourlyProgress; + const progress = hourlyProgress(work.progress); const row = await tx.tbankSyncWork.findFirst({ where: { userId: attempt.userId, id: child.id }, select: { attemptId: true }, }); - const updated = await tx.tbankSyncWork.updateMany({ - where: { ...attempt, state: 'running' }, - data: { - progress: { - attempts: { - ...progress.attempts, - ...(row ? { [child.id]: row.attemptId } : {}), - }, - children: [...(progress.children ?? []), child], - sections: progress.sections ?? [], + const updated = await claimSyncWork(tx, attempt, { + progress: { + attempts: { + ...progress.attempts, + ...(row ? { [child.id]: row.attemptId } : {}), }, + children: [...(progress.children ?? []), child], + sections: progress.sections ?? [], }, - }); - return Boolean(updated.count); + }, { withoutConnectionFence: true }); + return Boolean(updated); }); } @@ -397,7 +420,7 @@ export class TbankSyncRepository { }, }); if (!work) return null; - const progress = work.progress as HourlyProgress; + const progress = hourlyProgress(work.progress); let state: 'cancelled' | 'running' | HourlyProgress['result']; if (work.state === 'running') state = 'running'; else if (work.state === 'cancelled') state = 'cancelled'; diff --git a/src/modules/investments/tbank-sync/tbank-sync.service.ts b/src/modules/investments/tbank-sync/tbank-sync.service.ts deleted file mode 100644 index bdc37a8..0000000 --- a/src/modules/investments/tbank-sync/tbank-sync.service.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - ConflictException, - Injectable, - NotFoundException, -} from '@nestjs/common'; -import { randomUUID } from 'node:crypto'; - -import { TbankAccessService } from '../../../integrations/tbank/tbank.api'; -import { BrokerageOperationsSyncService } from '../operations/operations.api'; -import { TbankSyncStatusDto } from './tbank-sync.dto'; -import { - type ScreenIntent, - TbankSyncRepository, -} from './tbank-sync.repository'; - -@Injectable() -export class TbankSyncService { - private readonly runId = randomUUID(); - private readonly startedAt = new Date(); - constructor( - private readonly repository: TbankSyncRepository, - private readonly access: TbankAccessService, - private readonly operations: BrokerageOperationsSyncService, - ) {} - - async status(userId: string, id: string): Promise { - const connection = await this.access - .local(userId) - .catch((error: unknown) => { - if (error instanceof ConflictException) throw new NotFoundException(); - throw error; - }); - const saved = await this.repository.read(userId, connection.revision, id); - if (!saved) throw new NotFoundException(); - if (!saved.work.scopeKey.startsWith('screen:')) { - return this.sourceStatus(userId, saved.work); - } - const intent = saved.work.intent as unknown as ScreenIntent; - await this.access.local(userId, intent.scope.accountId); - const children = await Promise.all( - saved.children.map((child) => - child.state === 'superseded' - ? this.operations.observe(userId, child.id) - : Promise.resolve(child), - ), - ); - const running = children.some((child) => child.state === 'running'); - const failed = - saved.work.errorCode ?? - children.find((child) => child.errorCode)?.errorCode ?? - null; - const state = - saved.work.processRunId !== this.runId && - (!saved.progress.dispatched || running) - ? 'cancelled' - : !saved.progress.dispatched || running - ? 'running' - : failed - ? 'failed' - : children.some((child) => child.state === 'cancelled') - ? 'cancelled' - : 'succeeded'; - if ( - (await this.access.local(userId, intent.scope.accountId)).revision !== - connection.revision - ) { - throw new NotFoundException(); - } - return { - id, - ...intent, - state, - errorCode: failed, - }; - } - - private async sourceStatus( - userId: string, - work: NonNullable>>['work'], - ): Promise { - const observed = work.scopeKey.startsWith('operations:') - ? await this.operations.observe(userId, work.id) - : work; - const state = - observed.state === 'running' - ? observed.updatedAt < this.startedAt - ? 'cancelled' - : 'running' - : observed.state === 'failed' - ? 'failed' - : observed.state === 'cancelled' - ? 'cancelled' - : 'succeeded'; - if ((await this.access.local(userId)).revision !== work.revision) { - throw new NotFoundException(); - } - return { - id: work.id, - scope: null, - now: work.createdAt.toISOString(), - state, - errorCode: observed.errorCode, - }; - } -} diff --git a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts index c250038..bf2af15 100644 --- a/src/modules/investments/upcoming-events/upcoming-event.mapper.ts +++ b/src/modules/investments/upcoming-events/upcoming-event.mapper.ts @@ -1,13 +1,14 @@ -import type { MoexBondEvent } from '../../../integrations/moex/moex.api'; -import type { TbankDividend } from '../../../integrations/tbank/tbank.api'; +import type { MoexBondEvent } from '#src/integrations/moex/moex.api'; +import type { TbankDividend } from '#src/integrations/tbank/tbank.api'; import { decimalFromString, formatDecimal, multiply, -} from '../../../common/decimal'; -import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../common/time'; -import { decimal } from '../../../integrations/tbank/decimal'; +} from '#src/common/decimal'; +import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time'; +import { decimal } from '#src/integrations/tbank/decimal'; + import { UpcomingEventDto } from './upcoming-events.dto'; export function dividendEvent( diff --git a/src/modules/investments/upcoming-events/upcoming-events.api.ts b/src/modules/investments/upcoming-events/upcoming-events.api.ts new file mode 100644 index 0000000..deef34b --- /dev/null +++ b/src/modules/investments/upcoming-events/upcoming-events.api.ts @@ -0,0 +1 @@ +export { EventsErrorCode, UpcomingEventsService } from './upcoming-events.service'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts index d7eb524..aae1a08 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.spec.ts @@ -1,4 +1,4 @@ -jest.mock('../../../prisma/prisma.service', () => ({ +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class PrismaService {}, })); diff --git a/src/modules/investments/upcoming-events/upcoming-events.controller.ts b/src/modules/investments/upcoming-events/upcoming-events.controller.ts index 17bc59a..6529716 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.controller.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.controller.ts @@ -3,7 +3,8 @@ 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 { UpcomingEventsQueryDto, diff --git a/src/modules/investments/upcoming-events/upcoming-events.dto.ts b/src/modules/investments/upcoming-events/upcoming-events.dto.ts index 8d8353b..fa2656b 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.dto.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.dto.ts @@ -1,14 +1,14 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { IsDateString, IsOptional, Matches } from 'class-validator'; -import { MoneyDto } from '../../../common/money.dto'; +import { MoneyDto } from '#src/common/money.dto'; + import { AccountReferenceDto } from '../account-reference.dto'; import { - InvestmentReadQueryDto, InvestmentResourceMetaDto, } from '../investment-resource.dto'; -export class UpcomingEventsQueryDto extends InvestmentReadQueryDto { +export class UpcomingEventsQueryDto { @ApiPropertyOptional({ format: 'date', description: @@ -65,6 +65,14 @@ export class UpcomingEventDto { declare projectedAmount: MoneyDto | null; } +export class UpcomingEventsWindowDto { + @ApiProperty({ format: 'date' }) + declare from: string; + + @ApiProperty({ format: 'date' }) + declare to: string; +} + export class UpcomingEventsDto { @ApiProperty() declare accountId: string; @@ -72,6 +80,13 @@ export class UpcomingEventsDto { @ApiProperty({ format: 'date-time' }) declare calculatedAt: string; + @ApiProperty({ + type: UpcomingEventsWindowDto, + description: + 'Inclusive UTC window the saved forecast was calculated for. Unchanged by the requested from/to filter.', + }) + declare window: UpcomingEventsWindowDto; + @ApiProperty({ type: UpcomingEventDto, isArray: true }) declare events: UpcomingEventDto[]; diff --git a/src/modules/investments/upcoming-events/upcoming-events.module.ts b/src/modules/investments/upcoming-events/upcoming-events.module.ts index ab7ae36..523fe4e 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.module.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.module.ts @@ -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 { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { PortfolioModule } from '../portfolio/portfolio.module'; diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts index f26c890..98ca93a 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.spec.ts @@ -1,6 +1,7 @@ -import { decimalFromString } from '../../../common/decimal'; +import { decimalFromString } from '#src/common/decimal'; + import { bondEvent, dividendEvent } from './upcoming-event.mapper'; -jest.mock('../../../prisma/prisma.service', () => ({ +jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {}, })); const metadata = { diff --git a/src/modules/investments/upcoming-events/upcoming-events.service.ts b/src/modules/investments/upcoming-events/upcoming-events.service.ts index e36d9ae..3327e97 100644 --- a/src/modules/investments/upcoming-events/upcoming-events.service.ts +++ b/src/modules/investments/upcoming-events/upcoming-events.service.ts @@ -6,14 +6,15 @@ import { decimalFromString, formatDecimal, isZero, -} from '../../../common/decimal'; -import { MoneyDto } from '../../../common/money.dto'; -import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time'; +} from '#src/common/decimal'; +import { MoneyDto } from '#src/common/money.dto'; +import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time'; import { InstrumentMetadataService, metadataKey, TbankAccessService, -} from '../../../integrations/tbank/tbank.api'; +} from '#src/integrations/tbank/tbank.api'; + import { CatalogEnrichmentService } from '../instruments/instruments.api'; import { investmentResourceMeta } from '../investment-resource.mapper'; import { @@ -150,7 +151,7 @@ export class UpcomingEventsService { a.type.localeCompare(b.type) || a.ticker.localeCompare(b.ticker), ); - const candidate: undefined | UpcomingEventsDto = ready + const candidate: Omit | undefined = ready ? { accountId, calculatedAt: new Date().toISOString(), @@ -163,6 +164,7 @@ export class UpcomingEventsService { revision: connection.revision, membershipRevision: connection.membershipRevision, scopeKey: `events/${JSON.stringify([accountId, window.from, window.to])}`, + replacesLatest: query.from === undefined && query.to === undefined, portfolio: portfolio.meta, lastAttempt: hourlyAttempt( hourly, @@ -184,6 +186,7 @@ export class UpcomingEventsService { userId: string, accountId: string, query: UpcomingEventsQueryDto = {}, + walkStartedAt?: Date, ): Promise { const window = dateWindow(query); await this.access.local(userId, accountId); @@ -194,6 +197,7 @@ export class UpcomingEventsService { positions, window, false, + walkStartedAt, ); return launched; } @@ -203,6 +207,7 @@ export class UpcomingEventsService { positions: Position[], window: { from: string; to: string }, observe: boolean, + refreshBefore?: Date, ) { const metadata = await this.metadata.savedMany(positions); const sources: Awaited> = @@ -228,6 +233,7 @@ export class UpcomingEventsService { identities.map((entry) => [entry.uid, entry.ticker]), ), }, + refreshBefore, ); sources.push(...batch); for (const source of batch) { diff --git a/src/openapi.ts b/src/openapi.ts index 0857d4d..9c7e665 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -10,7 +10,7 @@ import { BankErrorCode } from './modules/banking/bank-cards/application/bank-car import { IncomeErrorCode } from './modules/investments/income/income-period'; import { InstrumentErrorCode } from './modules/investments/instruments/domain/instrument-error-code'; import { OperationErrorCode } from './modules/investments/operations/domain/operation-error-code'; -import { EventsErrorCode } from './modules/investments/upcoming-events/upcoming-events.service'; +import { EventsErrorCode } from './modules/investments/upcoming-events/upcoming-events.api'; export const API_PREFIX = 'api/v1'; diff --git a/src/prisma/to-json.spec.ts b/src/prisma/to-json.spec.ts new file mode 100644 index 0000000..cb509ec --- /dev/null +++ b/src/prisma/to-json.spec.ts @@ -0,0 +1,14 @@ +import { toJson } from './to-json'; + +it('produces the same bytes as the inline JSON round-trip', () => { + const value = { + a: 'x', + b: undefined, + c: new Date('2026-11-04T00:00:00.000Z'), + d: [{ e: 1 }], + }; + expect(toJson(value)).toEqual( + JSON.parse(JSON.stringify(value)) as object, + ); + expect(toJson(value)).toEqual({ a: 'x', c: '2026-11-04T00:00:00.000Z', d: [{ e: 1 }] }); +}); diff --git a/src/prisma/to-json.ts b/src/prisma/to-json.ts new file mode 100644 index 0000000..754b1e6 --- /dev/null +++ b/src/prisma/to-json.ts @@ -0,0 +1,9 @@ +import type { Prisma } from '../../generated/prisma/client'; + +// Single owner for the Prisma Json write codec (backend#170): a JSON +// round-trip drops undefined values and Date instances become ISO strings, +// which is exactly what the Json columns expect. Same operations as the +// inline copies it replaced, so written bytes are identical. +export function toJson(value: T): Prisma.InputJsonObject { + return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonObject; +} diff --git a/test/all-accounts.e2e-spec.ts b/test/all-accounts.e2e-spec.ts index c94be41..34c3a95 100644 --- a/test/all-accounts.e2e-spec.ts +++ b/test/all-accounts.e2e-spec.ts @@ -66,7 +66,7 @@ describe('All accounts HTTP boundary', () => { it.each(paths)('requires authentication for /all/%s', async (path) => { await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .expect(401); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); @@ -77,7 +77,7 @@ describe('All accounts HTTP boundary', () => { async (path) => { const response = await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .set('Authorization', `Bearer ${token}`) .expect(200); @@ -100,7 +100,7 @@ describe('All accounts HTTP boundary', () => { ])('validates /all/%s in the configured pipeline', async (path) => { await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/${path}${path.includes('?') ? '&' : '?'}sync=observe`, + `/api/v1/brokerage-accounts/all/${path}`, ) .set('Authorization', `Bearer ${token}`) .expect(400); diff --git a/test/bonds.e2e-spec.ts b/test/bonds.e2e-spec.ts index 246a97c..ff02c05 100644 --- a/test/bonds.e2e-spec.ts +++ b/test/bonds.e2e-spec.ts @@ -125,14 +125,14 @@ describe('Bond HTTP contract', () => { .set('Authorization', `Bearer ${token}`) .expect(200); expect(response.body).toEqual(metrics); - expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid, false); + expect(bonds.metrics).toHaveBeenCalledWith('bond-reader', uid); }); it('uses verified identity and supports a direct instrument URL', async () => { await request(app.getHttpServer()) .get(`/api/v1/bonds/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid, false); + expect(bonds.get).toHaveBeenCalledWith('bond-reader', uid); }); it.each(['SU26238RMFS4', 'su26238rmfs4', 'F', 'SU26238RMFS4@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -150,7 +150,6 @@ describe('Bond HTTP contract', () => { expect(bonds.getByTicker).toHaveBeenCalledWith( 'bond-reader', ticker, - false, ); }, ); @@ -185,7 +184,6 @@ describe('Bond HTTP contract', () => { expect(bonds.search).toHaveBeenCalledWith( 'bond-reader', 'SU26238RMFS4', - false, ); expect(response.body).toEqual({ data: [item], diff --git a/test/eslint-config.cjs b/test/eslint-config.cjs index 4a8b9d1..69fb37b 100644 --- a/test/eslint-config.cjs +++ b/test/eslint-config.cjs @@ -102,6 +102,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { PrismaService } from "../../../prisma/prisma.service";'], [controller, 'import { TbankClient } from "../../../integrations/tbank/tbank.api";'], [useCase, 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0); @@ -113,7 +114,7 @@ test('architecture imports enforce direct boundaries without banning DTOs or pur [controller, 'import { AuthService } from "../application/auth.service";'], [useCase, 'import { BankCardsRepository } from "../infrastructure/bank-cards.repository";'], [useCase, 'import { BankSummaryQueryDto } from "../presentation/bank-card.dto";'], - ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import { PrismaService } from "../../../../prisma/prisma.service";'], + ['src/modules/banking/bank-cards/application/bank-cards.service.ts', 'import type { Prisma } from "../../../../../generated/prisma/client";'], ]) { const [result] = await eslint.lintText(code, { filePath }); assert.equal(result.fatalErrorCount, 0); diff --git a/test/jest-e2e.json b/test/jest-e2e.json index 7645b6e..9161cb8 100644 --- a/test/jest-e2e.json +++ b/test/jest-e2e.json @@ -8,6 +8,8 @@ "^.+\\.(t|j)s$": "ts-jest" }, "moduleNameMapper": { + "^#generated/(.*)$": "/../generated/$1", + "^#src/(.*)$": "/../src/$1", "^(\\.{1,2}/.*)\\.js$": "$1" } } diff --git a/test/openapi-contract.e2e-spec.ts b/test/openapi-contract.e2e-spec.ts index 816eb0f..32f91693 100644 --- a/test/openapi-contract.e2e-spec.ts +++ b/test/openapi-contract.e2e-spec.ts @@ -28,31 +28,11 @@ describe('OpenAPI contract (integration)', () => { const document = createOpenApiDocument(app); const schemas = document.components?.schemas; expect(document.paths['/api/v1/tbank-sync']).toBeUndefined(); - expect(schemas?.TbankSyncStatusDto).toBeDefined(); - for (const [model, screen] of [ - ['IncomeInvestmentScreenDto', 'income'], - ['EventsInvestmentScreenDto', 'events'], - ]) { - // eslint-disable-next-line security/detect-object-injection -- Keys come from local test fixtures or numeric iteration, not external input. - const schema = schemas?.[model]; - assert(schema && !('$ref' in schema)); - expect(schema.required).toContain('screen'); - expect(schema.required).toContain('accountId'); - expect(schema).toMatchObject({ - properties: { - screen: { enum: [screen] }, - from: { type: 'string', format: 'date' }, - to: { type: 'string', format: 'date' }, - }, - }); - } - expect(schemas?.TimestampInvestmentScreenDto).toMatchObject({ - properties: { - from: { type: 'string', format: 'date-time' }, - to: { type: 'string', format: 'date-time' }, - }, - }); - expect(schemas).not.toHaveProperty('InvestmentScreenDto'); + expect(document.paths['/api/v1/tbank-sync/{id}']).toBeUndefined(); + expect(schemas?.TbankSyncStatusDto).toBeUndefined(); + expect(schemas).not.toHaveProperty('IncomeInvestmentScreenDto'); + expect(schemas).not.toHaveProperty('EventsInvestmentScreenDto'); + expect(schemas).not.toHaveProperty('TimestampInvestmentScreenDto'); for (const scope of ['all', '{accountId}']) { for (const [resource, format] of [ ['income-analytics', 'date'], @@ -77,6 +57,15 @@ describe('OpenAPI contract (integration)', () => { } }); + it('does not advertise the retired observe query on investment reads', () => { + const document = createOpenApiDocument(app); + for (const [path, resource] of Object.entries(document.paths)) { + if (!path.startsWith('/api/v1/')) continue; + const parameters = resource.get?.parameters ?? []; + expect(parameters.some((parameter) => 'name' in parameter && parameter.name === 'sync')).toBe(false); + } + }); + it('exposes only the selected income dependency role and no comparison data', () => { const schemas = createOpenApiDocument(app).components?.schemas; expect(schemas?.IncomeAnalyticsResourceDto).toMatchObject({ diff --git a/test/stocks.e2e-spec.ts b/test/stocks.e2e-spec.ts index ca1b8c0..5220ff5 100644 --- a/test/stocks.e2e-spec.ts +++ b/test/stocks.e2e-spec.ts @@ -136,7 +136,7 @@ describe('Stock HTTP contract', () => { .get(`/api/v1/stocks/${uid}`) .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid, false); + expect(stocks.get).toHaveBeenCalledWith('stock-reader', uid); }); it.each(['SBERP', 'sberp', 'F', 'SBER@GS'])( 'resolves ticker %s through the authenticated endpoint', @@ -154,7 +154,6 @@ describe('Stock HTTP contract', () => { expect(stocks.getByTicker).toHaveBeenCalledWith( 'stock-reader', ticker, - false, ); }, ); @@ -186,7 +185,7 @@ describe('Stock HTTP contract', () => { .get('/api/v1/stocks?query=SBER') .set('Authorization', `Bearer ${token}`) .expect(200); - expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER', false); + expect(stocks.search).toHaveBeenCalledWith('stock-reader', 'SBER'); expect(response.body).toEqual({ data: [item], meta: { availability: 'complete' }, diff --git a/test/tbank-candles-persistence.e2e-spec.ts b/test/tbank-candles-persistence.e2e-spec.ts index eb041e8..a03c661 100644 --- a/test/tbank-candles-persistence.e2e-spec.ts +++ b/test/tbank-candles-persistence.e2e-spec.ts @@ -199,7 +199,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ).toBe(false); expect((await repository.read('uid', range)).coverage).toEqual([]); }); - it('coalesces twenty reads, makes observe passive and reuses closed windows after reopen', async () => { + it('coalesces twenty reads and reuses closed windows after reopen', async () => { const service = createService(); const input = { uid: 'uid', @@ -216,7 +216,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => { ); expect(cold.every((row) => row.data === null)).toBe(true); expect(await prisma.tbankSyncWork.count()).toBe(1); - await service.read('owner', input, true); release([]); await settled(); const count = upstream.getCandles.mock.calls.length; @@ -239,7 +238,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { where: { isComplete: false }, data: { fetchedAt: new Date(0) }, }); - const stale = await reopened.read('owner', input, true); + const stale = await reopened.read('owner', input); expect(stale.meta.availability).toBe('available'); expect(stale.meta.completeness).toBe('complete'); expect(stale.meta.freshness).toBe('stale'); @@ -251,7 +250,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(count + 1); }); - it('recovers abandoned work only on a new ordinary request', async () => { + it('recovers abandoned work on an ordinary request', async () => { const input = { uid: 'uid', instrumentType: 'share' as const, @@ -265,12 +264,6 @@ describe('atomic shared candle reconciliation (SQLite)', () => { new Set(), ); const reopened = createService(); - await reopened.read('owner', input, true); - expect(upstream.getCandles).not.toHaveBeenCalled(); - expect( - (await repository.work('owner', 'revision', 'candles:uid:month')) - ?.attemptId, - ).toBe(abandoned?.work.attemptId); await reopened.read('owner', input); await settled(); expect(upstream.getCandles).toHaveBeenCalledTimes(1); @@ -302,7 +295,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { await settled(); expect(await prisma.tbankCandleCoverage.findMany()).toEqual(before); expect(await prisma.tbankCandle.count()).toBe(0); - const read = await service.read('owner', input, true); + const read = await service.read('owner', input); expect(read.meta.lastAttempt.state).toBe('failed'); expect(read.data?.candles).toEqual([]); }); @@ -474,7 +467,7 @@ describe('atomic shared candle reconciliation (SQLite)', () => { }; await service.read('owner', input); await settled(); - const result = await service.read('owner', input, true); + const result = await service.read('owner', input); expect(result.data?.currency).toBeNull(); expect(result.data?.candles).toEqual([ { time: now.toISOString(), close: '99.123456789', isComplete: false }, diff --git a/test/tbank-catalog-discovery.e2e-spec.ts b/test/tbank-catalog-discovery.e2e-spec.ts deleted file mode 100644 index 3dc6f2b..0000000 --- a/test/tbank-catalog-discovery.e2e-spec.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { ConfigService } from '@nestjs/config'; -import assert from 'node:assert/strict'; - -import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; -import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; -import { CatalogDiscoveryService } from '../src/modules/investments/instruments/application/catalog-discovery.service'; -import { - CatalogDiscoveryRepository, - type CatalogInstrument, - type CatalogQuery, -} from '../src/modules/investments/instruments/infrastructure/catalog-discovery.repository'; -import { PrismaService } from '../src/prisma/prisma.service'; - -// Real transactions prove query normalization, confirmed-empty identity and cross-user isolation of shared reads. -describe('shared catalog discovery (SQLite)', () => { - const prisma = new PrismaService( - new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), - ); - const tokens = new TbankTokenService( - new ConfigService({ - TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, - }) as never, - ); - const upstream = { findShares: jest.fn(), findBonds: jest.fn() }; - const access = new TbankAccessService(prisma, tokens); - const repository = new CatalogDiscoveryRepository(prisma); - const createService = () => - new CatalogDiscoveryService( - repository, - access, - upstream as never, - { requestMany: jest.fn().mockResolvedValue([]) } as never, - ); - let service: CatalogDiscoveryService; - const query: CatalogQuery = { instrumentType: 'share', query: 'SBER' }; - const instrument = { - uid: 'uid', - name: 'Synthetic share', - ticker: 'SBER', - isin: 'TEST', - instrumentKind: 'INSTRUMENT_TYPE_SHARE', - }; - const seed = (userId: string) => - prisma.tbankConnection.create({ - data: { - userId, - revision: `${userId}-revision`, - encryptedToken: tokens.encrypt(`synthetic-${userId}`), - connectedAt: new Date(), - }, - }); - // Writes a finished discovery directly, so the read path is tested without any producer. - async function seedCatalog( - query: string, - instruments: Array<{ payload: CatalogInstrument; uid: string }>, - ) { - for (const item of instruments) { - await prisma.tbankInstrument.create({ - data: { - provider: 'tbank', - uid: item.uid, - instrumentType: 'share', - metadata: {}, - fetchedAt: new Date(), - }, - }); - await prisma.tbankInstrumentSource.create({ - data: { - provider: 'tbank', - instrumentUid: item.uid, - source: 'tbank', - kind: 'catalog', - rangeKey: 'share', - payload: item.payload, - fetchedAt: new Date(), - }, - }); - } - await prisma.tbankCatalogDiscovery.create({ - data: { - provider: 'tbank', - instrumentType: 'share', - query, - instrumentUids: instruments.map((item) => item.uid), - fetchedAt: new Date(), - }, - }); - } - beforeAll(async () => { - await prisma.$connect(); - for (const id of ['owner', 'other']) { - await prisma.user.create({ - data: { id, email: `${id}@example.invalid`, passwordHash: 'synthetic' }, - }); - } - }); - afterAll(async () => prisma.$disconnect()); - beforeEach(async () => { - await prisma.tbankConnection.deleteMany(); - await prisma.tbankCatalogDiscovery.deleteMany(); - await prisma.tbankInstrumentSource.deleteMany(); - await prisma.tbankInstrument.deleteMany(); - await seed('owner'); - await seed('other'); - jest.resetAllMocks(); - service = createService(); - }); - it('treats an unknown query as unavailable and a seeded query as available for another user', async () => { - const cold = await service.read('owner', { ...query, query: ' sber ' }); - expect(cold.data).toBeNull(); - expect(cold.meta.availability).toBe('temporarily-unavailable'); - await seedCatalog('SBER', [{ uid: 'uid', payload: instrument }]); - const warm = await service.read('other', query); - expect(warm.data).toEqual([instrument]); - expect(warm.meta.availability).toBe('available'); - expect(upstream.findShares).not.toHaveBeenCalled(); - }); - it('keeps a confirmed empty result distinct from an unknown query', async () => { - await seedCatalog('GAZP', []); - const empty = await service.read('owner', { ...query, query: 'GAZP' }); - expect(empty.data).toEqual([]); - expect(empty.meta.availability).toBe('available'); - const unknown = await service.read('owner', { ...query, query: 'LKOH' }); - expect(unknown.data).toBeNull(); - }); - it('keeps the stored selection order and does not expose private fields', async () => { - const first = { ...instrument, uid: 'first', name: 'First' }; - const second = { ...instrument, uid: 'second', name: 'Second' }; - await seedCatalog('SBER', [ - { uid: 'second', payload: second }, - { uid: 'first', payload: first }, - ]); - const result = await service.read('owner', query); - assert(result.data); - expect(result.data.map((item) => item.uid)).toEqual(['second', 'first']); - expect(result.data[0]).not.toHaveProperty('accountId'); - }); -}); diff --git a/test/tbank-hourly-lifecycle.e2e-spec.ts b/test/tbank-hourly-lifecycle.e2e-spec.ts index b9bdae5..60b0988 100644 --- a/test/tbank-hourly-lifecycle.e2e-spec.ts +++ b/test/tbank-hourly-lifecycle.e2e-spec.ts @@ -640,8 +640,6 @@ describe('hourly attempt lifecycle (SQLite)', () => { { getLastPrices: jest.fn().mockResolvedValue([]) } as never, logger as never, {} as never, - {} as never, - {} as never, ); jest .spyOn(catalogRepository, 'publishSource') diff --git a/test/tbank-income-local.e2e-spec.ts b/test/tbank-income-local.e2e-spec.ts index 6484605..1eddbb2 100644 --- a/test/tbank-income-local.e2e-spec.ts +++ b/test/tbank-income-local.e2e-spec.ts @@ -34,12 +34,9 @@ describe('local income completeness and exact money (SQLite)', () => { const get = ( account = 'all', period = 'from=2025-01-01&to=2025-01-31', - observe = true, ) => request(app.getHttpServer()) - .get( - `/api/v1/brokerage-accounts/${account}/income-analytics?${period}${observe ? '&sync=observe' : ''}`, - ) + .get(`/api/v1/brokerage-accounts/${account}/income-analytics?${period}`) .set('Authorization', authorization) .timeout(2000); const read = async (account = 'all', period?: string) => @@ -127,7 +124,7 @@ describe('local income completeness and exact money (SQLite)', () => { } } }); - it('returns exact selected sums; reopen and observe need no RPC', async () => { + it('returns exact selected sums and reuses covered reads without RPC', async () => { for (let index = 0; index < 2; index++) { const result = await read(); assert(result.data); @@ -210,7 +207,9 @@ describe('local income completeness and exact money (SQLite)', () => { expect( (await read('a', 'from=2025-03-01&to=2025-03-31')).data?.totalsByCurrency, ).toEqual([]); - expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); + expect(upstream.getOperationsByCursor).toHaveBeenCalledWith( + 'synthetic', expect.objectContaining({ accountId: 'b' }), + ); }); it('counts corrected identities once for the selected period', async () => { await prisma.tbankOperation.updateMany({ @@ -274,7 +273,7 @@ describe('local income completeness and exact money (SQLite)', () => { expect(after.meta.lastAttempt.state).toBe('none'); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); - it('retains current-month observed completeness and does not renew source timestamps', async () => { + it('retains current-month completeness and does not renew source timestamps', async () => { const now = new Date(); const today = moscowDate(now); const start = new Date(`${today.slice(0, 7)}-01T00:00:00+03:00`); @@ -283,7 +282,7 @@ describe('local income completeness and exact money (SQLite)', () => { await coverage(accountId, start, asOf); } const response = await request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/all/income-summary?sync=observe') + .get('/api/v1/brokerage-accounts/all/income-summary') .set('Authorization', authorization) .expect(200); const result = response.body as IncomeSummaryResourceDto; @@ -302,58 +301,32 @@ describe('local income completeness and exact money (SQLite)', () => { where: { userId: 'income-owner' }, data: { membershipAt: new Date('2020-01-01Z') }, }); - const response = await get('a', undefined, false).expect(200); + const response = await get('a').expect(200); const result = response.body as IncomeAnalyticsResourceDto; expect(result.data).not.toBeNull(); expect(result.meta.completeness).toBe('complete'); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); - it('cold income imports only the missing requested interval', async () => { + it('ignores retired sync=observe on income reads and keeps the cold response incomplete', async () => { await prisma.tbankConnection.update({ where: { userId: 'income-owner' }, data: { membershipAt: new Date('2020-01-01Z') }, }); - let release: () => void = () => { - throw new Error('Upstream not initialized'); - }; - const blocked = new Promise<{ hasNext: boolean; items: never[] }>((resolve) => { - release = () => resolve({ hasNext: false, items: [] }); - }); - upstream.getOperationsByCursor.mockReturnValue(blocked); - const first = await get('a', 'from=2025-02-01&to=2025-02-28', false).expect(200); - const cold = first.body as IncomeAnalyticsResourceDto; - release(); - expect(cold.data).toBeNull(); - expect(cold.meta.completeness).toBe('incomplete'); - let result: IncomeAnalyticsResourceDto = cold; - for (let attempt = 0; attempt < 100; attempt++) { - const response = await get('a', 'from=2025-02-01&to=2025-02-28').expect(200); - result = response.body as IncomeAnalyticsResourceDto; - if (result.meta.completeness === 'complete') break; - await new Promise((resolve) => setTimeout(resolve, 10)); - } - expect(result.data).not.toBeNull(); - expect(result.meta.completeness).toBe('complete'); + const response = await request(app.getHttpServer()) + .get('/api/v1/brokerage-accounts/a/income-analytics?from=2025-02-01&to=2025-02-28&sync=observe') + .set('Authorization', authorization) + .expect(200); + const result = response.body as IncomeAnalyticsResourceDto; + expect(result.data).toBeNull(); + expect(result.meta.completeness).toBe('incomplete'); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); - expect(upstream.getOperationsByCursor.mock.calls).toEqual([ - ['synthetic', expect.objectContaining({ - accountId: 'a', - from: { seconds: '1738357199' }, - to: { seconds: '1738368001' }, - })], - ['synthetic', expect.objectContaining({ - accountId: 'a', - from: { seconds: '1738367999' }, - to: { seconds: '1740776401' }, - })], - ]); }); it('returns while a cold upstream is blocked and validates access/period before scheduling', async () => { - await get('absent', undefined, false).expect(404); - await get('a', 'from=2025-02-31&to=2025-03-01', false).expect(400); + await get('absent').expect(404); + await get('a', 'from=2025-02-31&to=2025-03-01').expect(400); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); - const response = await get('all', undefined, false).expect(200); + const response = await get('all').expect(200); expect((response.body as IncomeAnalyticsResourceDto).data).not.toBeNull(); }); it('plans only the selected period for a leap day without scheduling it', async () => { @@ -361,7 +334,7 @@ describe('local income completeness and exact money (SQLite)', () => { expect(result.meta.dependencies.map(({ role }) => role)).toEqual([ 'selected', ]); - expect(result.meta.dependencies[0].availability).toBe('temporarily-unavailable'); + expect(result.meta.dependencies[0].availability).toBe('initial-loading'); expect(result.data).toBeNull(); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); diff --git a/test/tbank-ledger-local.e2e-spec.ts b/test/tbank-ledger-local.e2e-spec.ts index 9d35709..b3c2f6f 100644 --- a/test/tbank-ledger-local.e2e-spec.ts +++ b/test/tbank-ledger-local.e2e-spec.ts @@ -30,10 +30,11 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }; const auth = (owner = 'owner') => `Bearer ${new JwtService().sign({ sub: owner, type: 'access' }, { secret: process.env.JWT_SECRET })}`; + const cursorParam = (cursor?: null | string) => (cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''); const get = (query = '', account = 'all', owner = 'owner') => request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/${account}/operations?${period}&${query}`, + `/api/v1/brokerage-accounts/${account}/operations?${[period, query].filter(Boolean).join('&')}`, ) .set('Authorization', auth(owner)) .timeout(30_000); @@ -138,7 +139,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { let complete = false; for (let attempt = 0; attempt < 100; attempt++) { const response = await request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28&sync=observe') + .get('/api/v1/brokerage-accounts/a/operations?from=2025-02-01&to=2025-02-28') .set('Authorization', auth()) .expect(200); const result = response.body as BrokerageOperationsResourceDto; @@ -162,7 +163,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const ids: string[] = []; do { const response = await get( - `limit=2${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `limit=2${cursorParam(cursor)}`, ).expect(200); const result = response.body as BrokerageOperationsResourceDto; expect(result.meta.availability).toBe('available'); @@ -243,7 +244,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { do { const response = await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1&sync=observe${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `/api/v1/brokerage-accounts/a/operations?from=${interval.from.toISOString()}&to=2025-01-02T00:00:00.900Z&limit=1${cursorParam(cursor)}`, ) .set('Authorization', auth()) .expect(200); @@ -258,7 +259,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { const income = ( await request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/a/income-analytics?${period}&sync=observe`, + `/api/v1/brokerage-accounts/a/income-analytics?${period}`, ) .set('Authorization', auth()) .expect(200) @@ -291,10 +292,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { periodKind === 'default' ? '' : `&to=${observedAt.toISOString().slice(0, 10)}${periodKind === 'today timestamp' ? 'T23:59:59.999Z' : ''}`; - const page = (cursor?: string, observe = false) => + const page = (cursor?: string) => request(app.getHttpServer()) .get( - `/api/v1/brokerage-accounts/all/operations?${observe ? 'sync=observe&' : ''}limit=1${query}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + `/api/v1/brokerage-accounts/all/operations?limit=1${query}${cursorParam(cursor)}`, ) .set('Authorization', auth()); const first = (await page().expect(200)) @@ -307,7 +308,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(second.meta.availability).toBe('available'); expect(second.meta.completeness).toBe('complete'); expect(second.meta.coverage?.missingRanges).toEqual([]); - const observed = (await page(first.data.nextCursor, true).expect(200)) + const observed = (await page(first.data.nextCursor).expect(200)) .body as BrokerageOperationsResourceDto; expect(observed.meta.availability).toBe('available'); expect(observed.meta.completeness).toBe('complete'); @@ -317,7 +318,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { it('keeps data revisions stable while observational reads expose current coverage', async () => { const observe = () => request(app.getHttpServer()) - .get('/api/v1/brokerage-accounts/all/operations?sync=observe&limit=100') + .get('/api/v1/brokerage-accounts/all/operations?limit=100') .set('Authorization', auth()); const first = (await observe().expect(200)) .body as BrokerageOperationsResourceDto; @@ -340,7 +341,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('reads shared metadata from SQLite without invalidating a row cursor', async () => { - const before = (await get('limit=1&sync=observe').expect(200)) + const before = (await get('limit=1').expect(200)) .body as BrokerageOperationsResourceDto; assert(before.data?.nextCursor); const { revision } = await prisma.tbankConnection.findUniqueOrThrow({ @@ -371,22 +372,22 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { new Date(), ); await prisma.$disconnect(); - const after = (await get('limit=1&sync=observe').expect(200)) + const after = (await get('limit=1').expect(200)) .body as BrokerageOperationsResourceDto; expect(after.data?.items[0].logoName).toBe('saved.png'); expect(after.meta.dataRevision).toBe(before.meta.dataRevision); await get( - `limit=1&sync=observe&cursor=${encodeURIComponent(before.data.nextCursor)}`, + `limit=1&cursor=${encodeURIComponent(before.data.nextCursor)}`, ).expect(200); expect(upstream.getOperationsByCursor).not.toHaveBeenCalled(); }); it('binds a signed cursor to equivalent filters, user, membership, row revision and connection; status alone does not break it', async () => { const first = ( - await get('limit=1&categories=trade,income&sync=observe').expect(200) + await get('limit=1&categories=trade,income').expect(200) ).body as BrokerageOperationsResourceDto; assert(first.data?.nextCursor); const cursor = encodeURIComponent(first.data.nextCursor); - const next = `limit=1&categories=income,trade,income&sync=observe&cursor=${cursor}`; + const next = `limit=1&categories=income,trade,income&cursor=${cursor}`; await prisma.tbankOperationCoverage.updateMany({ data: { fetchedAt: new Date(0) }, }); @@ -400,10 +401,10 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { data: { operationsRevision: { increment: 1 } }, }); await get(next).expect(400); - const single = (await get('limit=1&sync=observe', 'a').expect(200)) + const single = (await get('limit=1', 'a').expect(200)) .body as BrokerageOperationsResourceDto; assert(single.data?.nextCursor); - const singleNext = `limit=1&sync=observe&cursor=${encodeURIComponent(single.data.nextCursor)}`; + const singleNext = `limit=1&cursor=${encodeURIComponent(single.data.nextCursor)}`; await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipRevision: { increment: 1 } }, @@ -431,7 +432,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { }); const partial = ( await get( - `instrumentUid=${uid}&categories=income&limit=100&sync=observe`, + `instrumentUid=${uid}&categories=income&limit=100`, 'a', ).expect(200) ).body as BrokerageOperationsResourceDto; @@ -439,7 +440,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(partial.meta.completeness).toBe('incomplete'); expect(partial.data).toBeNull(); expect(partial.meta.coverage?.missingRanges.length).toBeGreaterThan(0); - const whole = (await get('sync=observe', 'a').expect(200)) + const whole = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(whole.meta.availability).toBe('available'); expect(whole.meta.completeness).toBe('complete'); @@ -450,7 +451,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await prisma.tbankAccount.updateMany({ data: { openedOn: null } }); await prisma.tbankOperationCoverage.deleteMany({ where: { accountId: 'a' } }); await prisma.tbankSyncWork.deleteMany({ where: { userId: 'owner' } }); - const result = (await get('sync=observe', 'a').expect(200)) + const result = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.meta.coverage?.historyBoundary).toBe('unknown'); expect(result.meta.coverage?.historyState).toBe('missing'); @@ -474,7 +475,7 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { expect(cold.data).toBeNull(); expect(cold.meta.availability).toBe('initial-loading'); expect(cold.meta.completeness).toBe('incomplete'); - await get('sync=observe', 'a').expect(200); + await get('', 'a').expect(200); expect(upstream.getOperationsByCursor).toHaveBeenCalledTimes(1); // eslint-disable-next-line sonarjs/no-extra-arguments -- The typed callback is assigned the promise resolver before invocation. unblock({ hasNext: false, items: [] }); @@ -485,12 +486,12 @@ describe('persistent ledger HTTP and cursor (SQLite)', () => { await delay(5); } const count = upstream.getOperationsByCursor.mock.calls.length; - const result = (await get('sync=observe', 'a').expect(200)) + const result = (await get('', 'a').expect(200)) .body as BrokerageOperationsResourceDto; expect(result.data?.items).toEqual([]); expect(result.meta.availability).toBe('available'); expect(result.meta.completeness).toBe('complete'); - await get('sync=observe', 'a').expect(200); + await get('', 'a').expect(200); expect(upstream.getOperationsByCursor.mock.calls.length).toBeGreaterThanOrEqual(count); }); }); diff --git a/test/tbank-local-reads.e2e-spec.ts b/test/tbank-local-reads.e2e-spec.ts index 58aa495..be0c72d 100644 --- a/test/tbank-local-reads.e2e-spec.ts +++ b/test/tbank-local-reads.e2e-spec.ts @@ -153,19 +153,19 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { encryptedToken: app.get(TbankTokenService).encrypt('synthetic'), }, }); - const cold = await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(200); + const cold = await get('brokerage-accounts/unknown/portfolio').expect(200); expect((cold.body as PortfolioResourceDto).data).toBeNull(); expect((cold.body as PortfolioResourceDto).meta.availability).toBe( 'temporarily-unavailable', ); - await get('brokerage-accounts?sync=observe').expect(200); + await get('brokerage-accounts').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).not.toHaveBeenCalled(); const connection = await prisma.tbankConnection.findUniqueOrThrow({ where: { userId: 'owner' } }); await prisma.tbankAccount.create({ data: { userId: 'owner', revision: connection.revision, accountId: 'account', name: 'Synthetic', type: 'ACCOUNT_TYPE_TINKOFF', upstreamOrder: 0 } }); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipAt: new Date() } }); - await get('brokerage-accounts/unknown/portfolio?sync=observe').expect(404); - await get('brokerage-accounts?sync=observe', 'other').expect(409); + await get('brokerage-accounts/unknown/portfolio').expect(404); + await get('brokerage-accounts', 'other').expect(409); await get('brokerage-accounts?sync=unexpected').expect(200); }); @@ -192,7 +192,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await seedMembership(); const blocked = deferred(); upstream.getPortfolio.mockReturnValueOnce(blocked.promise); - const cold = await get('brokerage-accounts?sync=observe').expect(200); + const cold = await get('brokerage-accounts').expect(200); expect( (cold.body as BrokerageAccountsResourceDto).data?.[0].portfolioSummary, ).toBeNull(); @@ -270,7 +270,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { if (work) await work.done; await settled(); const before = ( - await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + await get('brokerage-accounts/account/portfolio').expect(200) ).body as PortfolioResourceDto; const fetchedAt = new Date(); await prisma.tbankInstrument.create({ @@ -307,7 +307,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { update: { matchedUids: ['enriched-stock'], fetchedAt }, }); const after = ( - await get('brokerage-accounts/account/portfolio?sync=observe').expect(200) + await get('brokerage-accounts/account/portfolio').expect(200) ).body as PortfolioResourceDto; expect(after.data?.positions[0]).toMatchObject({ displayName: 'Enriched name', @@ -333,7 +333,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { where: { userId: 'owner', scopeKey: 'portfolio:account' }, data: { state: 'complete' }, }); - const stale = await get('brokerage-accounts/account/portfolio?sync=observe').expect(200); + const stale = await get('brokerage-accounts/account/portfolio').expect(200); const previous = (stale.body as PortfolioResourceDto).data; expect(previous?.isStale).toBe(true); expect(previous?.lastSuccessfulAt).toBe(new Date(0).toISOString()); @@ -343,7 +343,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' }, }); const failed = await get( - 'brokerage-accounts/account/portfolio?sync=observe', + 'brokerage-accounts/account/portfolio', ).expect(200); expect((failed.body as PortfolioResourceDto).data).toEqual(previous); expect((failed.body as PortfolioResourceDto).meta.lastAttempt.state).toBe('none'); @@ -403,7 +403,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }, ]); const income = await get( - 'brokerage-accounts/account/income-analytics?sync=observe', + 'brokerage-accounts/account/income-analytics', ).expect(200); const incomeMeta = (income.body as IncomeAnalyticsResourceDto).meta; expect(incomeMeta.lastAttempt.state).toBe('partial'); @@ -443,7 +443,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }); const blocked = deferred(); upstream.getOpenAccounts.mockReturnValueOnce(blocked.promise); - await get('brokerage-accounts?sync=observe').expect(200); + await get('brokerage-accounts').expect(200); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect( ( @@ -468,9 +468,9 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { where: { id: 'abandoned-membership' }, data: { state: 'complete' }, }); - expect((await get('brokerage-accounts/account/portfolio?sync=observe').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); + expect((await get('brokerage-accounts/account/portfolio').expect(200)).body).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(await prisma.tbankPortfolio.count()).toBe(0); - expect((await get('brokerage-accounts?sync=observe')).body).toMatchObject({ + expect((await get('brokerage-accounts')).body).toMatchObject({ data: [{ id: 'account', portfolioSummary: null }], }); }); @@ -510,7 +510,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { } await delay(5); } - const summaries = (await get('brokerage-accounts?sync=observe').expect(200)) + const summaries = (await get('brokerage-accounts').expect(200)) .body as BrokerageAccountsResourceDto; expect(summaries.data?.[0].portfolioSummary).toMatchObject({ totalsByCurrency: [{ currency: 'RUB', value: '21.1' }], @@ -528,10 +528,10 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { expect(summaries.meta.availability).toBe('available'); expect(summaries.meta.completeness).toBe('complete'); expect( - (await get('brokerage-accounts/all/portfolio?sync=observe')).body, + (await get('brokerage-accounts/all/portfolio')).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect( - (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + (await get(`stocks/${uid}/position`).expect(200)).body, ).toMatchObject({ data: null, meta: { availability: 'temporarily-unavailable', completeness: 'incomplete' } }); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankAccount.deleteMany({ where: { accountId: 'second' } }); @@ -542,7 +542,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await prisma.tbankConnection.updateMany({ data: { membershipRevision: { increment: 1 } }, }); - const position = await get(`stocks/${uid}/position?sync=observe`).expect( + const position = await get(`stocks/${uid}/position`).expect( 200, ); expect(position.body).toMatchObject({ @@ -551,12 +551,12 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { }); await prisma.$disconnect(); expect( - (await get(`stocks/${uid}/position?sync=observe`).expect(200)).body, + (await get(`stocks/${uid}/position`).expect(200)).body, ).toEqual(position.body); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); expect(upstream.getPortfolio).toHaveBeenCalledTimes(2); await prisma.tbankConnection.updateMany({ data: { invalidToken: true } }); - await get(`stocks/${uid}/position?sync=observe`).expect(400); + await get(`stocks/${uid}/position`).expect(400); }); it('uses membership TTL for an empty aggregate without starting work through observation', async () => { @@ -570,7 +570,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { membershipAt: new Date(Date.now() - 120_000) }, }); const fresh = await get( - 'brokerage-accounts/all/portfolio?sync=observe', + 'brokerage-accounts/all/portfolio', ).expect(200); expect((fresh.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((fresh.body as PortfolioResourceDto).data).toMatchObject({ @@ -580,7 +580,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { data: { membershipAt: new Date(Date.now() - 301_000) }, }); const stale = await get( - 'brokerage-accounts/all/portfolio?sync=observe', + 'brokerage-accounts/all/portfolio', ).expect(200); expect((stale.body as PortfolioResourceDto).meta.freshness).toBe('fresh'); expect((stale.body as PortfolioResourceDto).data?.isStale).toBe(false); @@ -595,7 +595,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await prisma.tbankConnection.updateMany({ data: { membershipAt: new Date() }, }); - const empty = await get('brokerage-accounts?sync=observe').expect(200); + const empty = await get('brokerage-accounts').expect(200); expect((empty.body as BrokerageAccountsResourceDto).data).toEqual([]); expect((empty.body as BrokerageAccountsResourceDto).meta.availability).toBe( 'available', @@ -625,7 +625,7 @@ describe('local accounts and portfolio HTTP (SQLite)', () => { await late.done; expect(await prisma.tbankPortfolio.count()).toBe(0); const replaced = await get( - 'brokerage-accounts/account/portfolio?sync=observe', + 'brokerage-accounts/account/portfolio', ).expect(200); expect((replaced.body as PortfolioResourceDto).data).toBeNull(); }); diff --git a/test/tbank-membership-reservation.e2e-spec.ts b/test/tbank-membership-reservation.e2e-spec.ts new file mode 100644 index 0000000..3095691 --- /dev/null +++ b/test/tbank-membership-reservation.e2e-spec.ts @@ -0,0 +1,107 @@ +import { ConfigService } from '@nestjs/config'; + +import { TbankAccessService } from '../src/integrations/tbank/tbank-access.service'; +import { TbankTokenService } from '../src/integrations/tbank/tbank-token.service'; +import { PrismaService } from '../src/prisma/prisma.service'; + +// Characterizes the membership reserve protocol on real SQLite transactions. +// Join by the same process while the attempt is active; launch after it is not. +describe('membership reservation protocol (SQLite)', () => { + const prisma = new PrismaService( + new ConfigService({ DATABASE_URL: process.env.DATABASE_URL }), + ); + const tokens = new TbankTokenService( + new ConfigService({ + TOKEN_ENCRYPTION_KEY: process.env.TOKEN_ENCRYPTION_KEY, + }) as never, + ); + const access = new TbankAccessService(prisma, tokens); + const userId = 'synthetic-membership-reservation'; + const revision = `${userId}-revision`; + + beforeAll(async () => { + await prisma.$connect(); + await prisma.tbankSyncWork.deleteMany({ where: { userId } }); + await prisma.tbankConnection.deleteMany({ where: { userId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.user.create({ + data: { + id: userId, + email: `${userId}@example.invalid`, + passwordHash: 'synthetic', + }, + }); + await prisma.tbankConnection.create({ + data: { + userId, + revision, + encryptedToken: tokens.encrypt('synthetic-membership'), + connectedAt: new Date(), + }, + }); + }); + + afterAll(async () => { + await prisma.tbankSyncWork.deleteMany({ where: { userId } }); + await prisma.tbankConnection.deleteMany({ where: { userId } }); + await prisma.user.deleteMany({ where: { id: userId } }); + await prisma.$disconnect(); + }); + + it('launches once, joins the active attempt by the same process, and relaunches after it finishes', async () => { + const processRunId = 'run-membership-a'; + const first = await access.reserveMembership( + userId, + revision, + processRunId, + new Set(), + true, + ); + expect(first?.launch).toBe(true); + const attemptId = first?.work.attemptId ?? ''; + + const joined = await access.reserveMembership( + userId, + revision, + processRunId, + new Set([attemptId]), + true, + ); + expect(joined?.launch).toBe(false); + expect(joined?.work.id).toBe(first?.work.id); + expect(joined?.work.attemptId).toBe(attemptId); + + // The attempt is no longer in the active set: this must not join it. + const relaunched = await access.reserveMembership( + userId, + revision, + processRunId, + new Set(), + true, + ); + expect(relaunched?.launch).toBe(true); + expect(relaunched?.work.attemptId).not.toBe(attemptId); + }); + + it('does not join an attempt started by another process', async () => { + const other = await access.reserveMembership( + userId, + revision, + 'run-membership-b', + new Set(), + true, + ); + expect(other?.launch).toBe(true); + const attemptId = other?.work.attemptId ?? ''; + + const foreign = await access.reserveMembership( + userId, + revision, + 'run-membership-c', + new Set([attemptId]), + true, + ); + expect(foreign?.launch).toBe(true); + expect(foreign?.work.attemptId).not.toBe(attemptId); + }); +}); diff --git a/test/upcoming-events.e2e-spec.ts b/test/upcoming-events.e2e-spec.ts index e338fee..1d7481c 100644 --- a/test/upcoming-events.e2e-spec.ts +++ b/test/upcoming-events.e2e-spec.ts @@ -73,16 +73,18 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { .set('Authorization', `Bearer ${auth(userId)}`) .timeout(1500); const events = ( - observe = true, scope = 'account', userId = 'owner', range = window, ) => get( - `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}${observe ? '&sync=observe' : ''}`, + `brokerage-accounts/${scope}/events?from=${range.from}&to=${range.to}`, userId, ); - const refresh = async (range = window, accountId = 'account') => { + const refresh = async ( + range: { from?: string; to?: string } = window, + accountId = 'account', + ) => { await app.get(UpcomingEventsService).refresh('owner', accountId, range); await settled(); }; @@ -275,7 +277,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { { currency: 'RUB', value: '6' }, { currency: 'USD', value: '20.000000002' }, ]); - const all = (await events(true, 'all').expect(200)) + const all = (await events('all').expect(200)) .body as UpcomingEventsResourceDto; expect( all.data?.events.every((event) => event.account?.id === 'account'), @@ -318,17 +320,23 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(upstream.getDividends).toHaveBeenCalledTimes(count); }); - it('never relabels another range or membership snapshot and rejects inaccessible accounts locally', async () => { + it('serves a disjoint range as an empty saved result and never relabels a membership snapshot', async () => { await refresh(); await settled(); - await events().expect(200); + const saved = (await events().expect(200)) + .body as UpcomingEventsResourceDto; const newRange = ( - await events(true, 'account', 'owner', { + await events('account', 'owner', { from: '2099-03-01', to: '2099-03-31', }).expect(200) ).body as UpcomingEventsResourceDto; - expect(newRange.data).toBeNull(); + expect(newRange.data).toEqual({ + ...saved.data, + events: [], + incomeTotalsByCurrency: [], + }); + expect(newRange.data?.window).toEqual(window); await prisma.tbankConnection.update({ where: { userId: 'owner' }, data: { membershipRevision: { increment: 1 } }, @@ -337,7 +345,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect( ((await events().expect(200)).body as UpcomingEventsResourceDto).data, ).toBeNull(); - await events(true, 'foreign').expect(404); + await events('foreign').expect(404); expect(upstream.getOpenAccounts).not.toHaveBeenCalled(); }); @@ -380,7 +388,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await get(`stocks/${shareUid}/dividends`).expect(200); await settled(); const before = ( - await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200) + await get(`stocks/${shareUid}/dividends`).expect(200) ).body as StockDividendsResourceDto; expect(before.data?.dividends[0].estimatedAmount).toEqual({ currency: 'RUB', @@ -409,7 +417,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); expect( ( - (await get(`stocks/${shareUid}/dividends?sync=observe`).expect(200)) + (await get(`stocks/${shareUid}/dividends`).expect(200)) .body as StockDividendsResourceDto ).data, ).toEqual(before.data); @@ -424,7 +432,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await get(`bonds/${bondUid}/coupons`).expect(200); await settled(); const coupons = ( - await get(`bonds/${bondUid}/coupons?sync=observe`).expect(200) + await get(`bonds/${bondUid}/coupons`).expect(200) ).body as BondCouponsResourceDto; expect(coupons.data?.coupons[0].estimatedAmount).toEqual({ currency: 'RUB', @@ -458,7 +466,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await portfolio([position(uid, '3', type)]); const path = `${kind}/${uid}/${payout}`; const read = async () => - (await get(`${path}?sync=observe`).expect(200)).body as + (await get(`${path}`).expect(200)).body as BondCouponsResourceDto | StockDividendsResourceDto; await get(path).expect(200); await settled(); @@ -605,7 +613,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { await settled(); const result = ( await get( - 'brokerage-accounts/account/events?from=2099-02-01&sync=observe', + 'brokerage-accounts/account/events?from=2099-02-01', ).expect(200) ).body as UpcomingEventsResourceDto; expect(result.data?.events.map((event) => event.date)).toEqual([ @@ -627,7 +635,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const calculatedAt = full.data?.calculatedAt; jest.clearAllMocks(); - const narrow = (await events(false, 'account', 'owner', { + const narrow = (await events('account', 'owner', { from: '2099-02-01', to: '2099-02-10', }).expect(200)).body as UpcomingEventsResourceDto; @@ -640,6 +648,247 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { expect(moex.bondEvents).not.toHaveBeenCalled(); }); + it('keeps the latest wide forecast when an explicit narrower range has a complete candidate', async () => { + await refresh(); + const full = (await events().expect(200)) + .body as UpcomingEventsResourceDto; + const narrowRange = { from: '2099-02-01', to: '2099-02-10' }; + await refresh(narrowRange); + + const narrow = (await events('account', 'owner', narrowRange).expect( + 200, + )).body as UpcomingEventsResourceDto; + + expect(narrow.data?.calculatedAt).toBe(full.data?.calculatedAt); + expect(narrow.data?.window).toEqual(window); + expect(narrow.data?.events.map((event) => event.date)).toEqual([ + '2099-02-01', + ]); + expect(await prisma.tbankForecast.count()).toBe(1); + }); + + describe('daily default window shift', () => { + const day0 = '2099-02-01'; + const day1 = '2099-02-02'; + const windowOf = (from: string, to: string) => ({ from, to }); + const w0 = windowOf(day0, '2099-03-03'); + const w1 = windowOf(day1, '2099-03-04'); + const dividend = (date: string) => ({ + recordDate: time(date), + dividendNet: { units: '10', nano: 1, currency: 'usd' }, + }); + const defaultEvents = async () => + (await get('brokerage-accounts/account/events').expect(200)) + .body as UpcomingEventsResourceDto; + // Only Date is faked: the app and SQLite helpers need real timers. + const clock = (date: string) => { + jest.useFakeTimers({ + doNotFake: [ + 'hrtime', 'nextTick', 'performance', 'queueMicrotask', + 'requestAnimationFrame', 'cancelAnimationFrame', + 'requestIdleCallback', 'cancelIdleCallback', 'setImmediate', + 'clearImmediate', 'setInterval', 'clearInterval', 'setTimeout', + 'clearTimeout', + ], + now: new Date(`${date}T12:00:00Z`), + }); + }; + afterEach(() => jest.useRealTimers()); + + it('publishes the new window as latest and serves it for stale or disjoint explicit ranges', async () => { + clock(day0); + upstream.getDividends.mockResolvedValue([ + dividend('2099-02-05'), + dividend('2099-03-04'), + ]); + await portfolio([position(shareUid, '2')]); + await refresh({}); + const first = await defaultEvents(); + expect(first.data?.window).toEqual(w0); + expect(first.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'USD', value: '20.000000002' }, + ]); + + jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); + await portfolio([position(shareUid, '5')]); + await refresh({}); + const second = await defaultEvents(); + + expect(second.data?.window).toEqual(w1); + expect(second.data?.calculatedAt).not.toBe(first.data?.calculatedAt); + expect(second.data?.events.map((event) => event.date)).toEqual([ + '2099-02-05', + '2099-03-04', + ]); + expect(second.data?.incomeTotalsByCurrency).toEqual([ + { currency: 'USD', value: '100.00000001' }, + ]); + const stale = (await events('account', 'owner', w0).expect(200)) + .body as UpcomingEventsResourceDto; + expect(stale.data?.calculatedAt).toBe(second.data?.calculatedAt); + expect(stale.data?.window).toEqual(w1); + const disjoint = ( + await events('account', 'owner', { + from: '2100-01-01', + to: '2100-01-31', + }).expect(200) + ).body as UpcomingEventsResourceDto; + expect(disjoint.data).toEqual({ + ...second.data, + events: [], + incomeTotalsByCurrency: [], + }); + expect(await prisma.tbankForecast.count()).toBe(2); + }); + + it('keeps the previous complete forecast when a holding calendar fails after the shift', async () => { + clock(day0); + upstream.getDividends.mockResolvedValue([dividend('2099-02-05')]); + await refresh({}); + const before = await defaultEvents(); + expect(before.data?.window).toEqual(w0); + + jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); + await portfolio([position(shareUid, '7'), position(newUid, '5')]); + upstream.getDividends.mockImplementation((_token: string, uid: string) => + uid === newUid + ? Promise.reject(new Error('new calendar unavailable')) + : Promise.resolve([dividend('2099-02-05')]), + ); + await refresh({}); + + expect((await defaultEvents()).data).toEqual(before.data); + expect(await prisma.tbankForecast.count()).toBe(1); + }); + + it('publishes the shifted window even when no source depends on it', async () => { + clock(day0); + await portfolio([]); + await refresh({}); + expect((await defaultEvents()).data?.window).toEqual(w0); + + jest.setSystemTime(new Date(`${day1}T12:00:00Z`)); + await refresh({}); + + expect((await defaultEvents()).data?.window).toEqual(w1); + }); + }); + + describe('hourly pass refreshes saved payout calendars', () => { + const date = new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10); + const dividend = (units: string, dividendType = 'Regular') => ({ + recordDate: time(date), + dividendType, + dividendNet: { units, currency: 'usd' }, + }); + // One hourly walk: the scheduler stamps it once and passes it to every account. + const startWalk = async () => { + await delay(10); + return new Date(); + }; + const pass = async (walkStartedAt: Date, accountId = 'account') => { + await app + .get(UpcomingEventsService) + .refresh('owner', accountId, {}, walkStartedAt); + await settled(); + }; + const latest = async () => + (await get('brokerage-accounts/account/events').expect(200)) + .body as UpcomingEventsResourceDto; + const dividendRow = () => + prisma.tbankInstrumentSource.findFirstOrThrow({ + where: { instrumentUid: shareUid, kind: 'dividends' }, + }); + + it('picks up a changed and then a cancelled payout on the next pass of the same window', async () => { + upstream.getDividends.mockResolvedValue([dividend('10')]); + await pass(await startWalk()); + expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ + { currency: 'USD', value: '20' }, + ]); + const first = await dividendRow(); + + upstream.getDividends.mockResolvedValue([dividend('30')]); + await pass(await startWalk()); + expect((await latest()).data?.incomeTotalsByCurrency).toEqual([ + { currency: 'USD', value: '60' }, + ]); + expect((await dividendRow()).dataRevision).toBe(first.dataRevision + 1); + + upstream.getDividends.mockResolvedValue([dividend('30', 'Cancelled')]); + await pass(await startWalk()); + expect((await latest()).data?.events).toEqual([]); + }); + + it('keeps the first complete calendar, time and forecast when the next refresh fails', async () => { + upstream.getDividends.mockResolvedValue([dividend('10')]); + await pass(await startWalk()); + const before = await latest(); + const row = await dividendRow(); + + upstream.getDividends.mockRejectedValue(new Error('outage')); + await pass(await startWalk()); + const after = await latest(); + + expect(after.data).toEqual(before.data); + expect(after.meta.lastSuccessfulAt).toBe(before.meta.lastSuccessfulAt); + expect( + after.meta.dependencies?.some( + (dependency) => dependency.lastAttempt.state === 'failed', + ), + ).toBe(true); + const kept = await dividendRow(); + expect(kept.dataRevision).toBe(row.dataRevision); + expect(kept.fetchedAt).toEqual(row.fetchedAt); + }); + + it('loads one shared calendar once per walk across accounts and leaves detail, GET and a plain refresh alone', async () => { + await prisma.tbankAccount.create({ + data: { + userId: 'owner', + revision: 'owner-revision', + accountId: 'second', + type: 'ACCOUNT_TYPE_TINKOFF', + name: 'Second', + upstreamOrder: 1, + }, + }); + await portfolio([position()], 'owner', 'second'); + upstream.getDividends.mockResolvedValue([dividend('10')]); + await pass(await startWalk()); + const detailCalls = () => + upstream.getShare.mock.calls.length + + upstream.getInstrumentByUid.mock.calls.length; + const detailBefore = detailCalls(); + expect(upstream.getDividends).toHaveBeenCalledTimes(1); + + const walk = await startWalk(); + await pass(walk, 'account'); + await pass(walk, 'second'); + expect(upstream.getDividends).toHaveBeenCalledTimes(2); + + await app.get(UpcomingEventsService).refresh('owner', 'account', {}); + await settled(); + await latest(); + await get('brokerage-accounts/all/events').expect(200); + expect(upstream.getDividends).toHaveBeenCalledTimes(2); + expect(detailCalls()).toBe(detailBefore); + }); + + it('re-reads the bond calendar whose key never changes, only on a walk pass', async () => { + await portfolio([position(bondUid, '3', 'BOND')]); + await pass(await startWalk()); + expect(moex.bondEvents).toHaveBeenCalledTimes(1); + + await app.get(UpcomingEventsService).refresh('owner', 'account', {}); + await settled(); + expect(moex.bondEvents).toHaveBeenCalledTimes(1); + + await pass(await startWalk()); + expect(moex.bondEvents).toHaveBeenCalledTimes(2); + }); + }); + it('discards a late calendar from a replaced connection and prevents access to old private forecasts', async () => { let release: (value: unknown[]) => void = () => {}; upstream.getDividends.mockReturnValueOnce( @@ -674,12 +923,16 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { 'to=2099-13-01', 'from=2099-02-01T00:00:00Z', 'from=2099-02-28&to=2099-02-01', - 'sync=invalid', ])('rejects invalid query before source reads: %s', async (query) => { await get(`brokerage-accounts/account/events?${query}`).expect(400); expect(upstream.getDividends).not.toHaveBeenCalled(); }); + it('ignores the retired sync query parameter', async () => { + const response = await get(`brokerage-accounts/account/events?from=2099-02-01&to=2099-02-28&sync=invalid`).expect(200); + expect(response.body).toHaveProperty('meta'); + }); + it('uses UTC today for an end-only range and includes the entire leap day', async () => { const today = new Date().toISOString().slice(0, 10); await refresh({ from: today, to: '2096-02-29' }); @@ -704,7 +957,7 @@ describe('saved payout calendars and complete forecasts (SQLite HTTP)', () => { const range = { from: '2096-02-29', to: '2096-02-29' }; await refresh(range); jest.clearAllMocks(); - const result = (await events(true, 'account', 'owner', range).expect(200)) + const result = (await events('account', 'owner', range).expect(200)) .body as UpcomingEventsResourceDto; expect(result.data?.events).toHaveLength(1); expect(result.data?.events[0]).toMatchObject({ diff --git a/tsconfig.json b/tsconfig.json index a65e90f..2db15ec 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,10 @@ "compilerOptions": { "module": "nodenext", "moduleResolution": "nodenext", + "paths": { + "#src/*": ["./src/*"], + "#generated/*": ["./generated/*"] + }, "resolvePackageJsonExports": true, "esModuleInterop": true, "isolatedModules": true,