4 Commits
Author SHA1 Message Date
ksv741 84433ee207 chore(release): 2.4.1
Backend verify / commits (pull_request) Successful in 1m13s
Backend verify / security (pull_request) Successful in 57s
Backend verify / verify (pull_request) Successful in 5m16s
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 59s
Backend verify / verify (push) Successful in 5m11s
Backend image / publish (push) Successful in 8m22s
2026-11-06 07:43:01 +03:00
codex-bot d792baaf59 refactor: retire legacy tbank-sync status and sync=observe (WealthLedger/backend#196)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m7s
Backend verify / verify (push) Failing after 7m39s
2026-11-05 22:33:14 +03:00
codex-bot 83b515a453 refactor: expose upcoming-events public api and guard its boundary (WealthLedger/backend#163) (#195)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m3s
Backend verify / verify (push) Failing after 6m52s
2026-11-04 23:52:27 +03:00
codex-bot 07cfd08df9 refactor: replace deep imports with native node aliases (WealthLedger/backend#165) (#193)
Backend verify / commits (push) Skipped
Backend verify / security (push) Successful in 1m6s
Backend verify / verify (push) Failing after 6m44s
2026-11-04 23:22:46 +03:00
121 changed files with 563 additions and 1076 deletions
+36
View File
@@ -1,5 +1,41 @@
# Changelog # Changelog
## [2.4.1](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.4.0...v2.4.1) (2026-11-06)
### Bug Fixes
* **events:** refresh saved payout calendars once per hourly pass (WealthLedger/backend[#53](https://git.personal.ksv741.ru/WealthLedger/backend/issues/53)) ([#157](https://git.personal.ksv741.ru/WealthLedger/backend/issues/157)) ([aeb4647](https://git.personal.ksv741.ru/WealthLedger/backend/commit/aeb4647fda3806a419ff3578834c34fff97ae3e8))
* **portfolio:** publish new complete forecast regardless of request window (WealthLedger/backend[#55](https://git.personal.ksv741.ru/WealthLedger/backend/issues/55)) ([#156](https://git.personal.ksv741.ru/WealthLedger/backend/issues/156)) ([169bee1](https://git.personal.ksv741.ru/WealthLedger/backend/commit/169bee1da5731a1330e995a4a508da544f791387))
### Refactoring
* **api:** describe bank operation create body as oneOf by kind (WealthLedger/backend[#172](https://git.personal.ksv741.ru/WealthLedger/backend/issues/172)) ([#173](https://git.personal.ksv741.ru/WealthLedger/backend/issues/173)) ([02e0d56](https://git.personal.ksv741.ru/WealthLedger/backend/commit/02e0d560c84606d6372cbac001dc94edc348995e))
* check persisted json payload shape instead of double casts ([#152](https://git.personal.ksv741.ru/WealthLedger/backend/issues/152)) ([#154](https://git.personal.ksv741.ru/WealthLedger/backend/issues/154)) ([a80bbbf](https://git.personal.ksv741.ru/WealthLedger/backend/commit/a80bbbf1e28954812d2d32c6e8f9f67f5a5d42a4))
* enforce repository boundary in application (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([e3ecb8c](https://git.personal.ksv741.ru/WealthLedger/backend/commit/e3ecb8c15e61ccd8de9acb4954a38932099eefc3))
* expose upcoming-events public api and guard its boundary (WealthLedger/backend[#163](https://git.personal.ksv741.ru/WealthLedger/backend/issues/163)) ([#195](https://git.personal.ksv741.ru/WealthLedger/backend/issues/195)) ([83b515a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/83b515a4536a6c534bcdec82f9f43f9005a44c35))
* extract catalog enrichment source loader (WealthLedger/backend[#169](https://git.personal.ksv741.ru/WealthLedger/backend/issues/169)) ([#186](https://git.personal.ksv741.ru/WealthLedger/backend/issues/186)) ([c5ccfea](https://git.personal.ksv741.ru/WealthLedger/backend/commit/c5ccfeadea7a9235ebdd6171d9b6f7b89ed2e1e7))
* inject TbankAccessService directly into stock screener ([#151](https://git.personal.ksv741.ru/WealthLedger/backend/issues/151)) ([#155](https://git.personal.ksv741.ru/WealthLedger/backend/issues/155)) ([554410a](https://git.personal.ksv741.ru/WealthLedger/backend/commit/554410a2716ce13d7202fd27a621c72734b5e6c8))
* move auth persistence into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([61c5525](https://git.personal.ksv741.ru/WealthLedger/backend/commit/61c55256e756b9187e1087215d35db3fd30ac148))
* move bank card queries into repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3d4801](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3d4801cfe67ecf301c58c894676b13b8833c4a6))
* move category persistence into bank repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([efac720](https://git.personal.ksv741.ru/WealthLedger/backend/commit/efac7201e06f49b264854566b2e3f73931f3f6cc))
* replace deep imports with native node aliases (WealthLedger/backend[#165](https://git.personal.ksv741.ru/WealthLedger/backend/issues/165)) ([#193](https://git.personal.ksv741.ru/WealthLedger/backend/issues/193)) ([07cfd08](https://git.personal.ksv741.ru/WealthLedger/backend/commit/07cfd08df9b13ac3fd13da153b12acf159329cea))
* retire legacy tbank-sync status and sync=observe (WealthLedger/backend[#196](https://git.personal.ksv741.ru/WealthLedger/backend/issues/196)) ([d792baa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/d792baaf59a1c204458ec249ee3ce907f3564455))
* route bank card reads through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([b3ca11d](https://git.personal.ksv741.ru/WealthLedger/backend/commit/b3ca11d1410899bb9c6707113ba9fd1f3b30e4bf))
* route bank operation queries through repository (WealthLedger/backend[#171](https://git.personal.ksv741.ru/WealthLedger/backend/issues/171)) ([cea77f6](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cea77f6d568809d989462c121f50b4e07e7efca5))
* share prisma json codec and check progress shape (WealthLedger/backend[#170](https://git.personal.ksv741.ru/WealthLedger/backend/issues/170)) ([#191](https://git.personal.ksv741.ru/WealthLedger/backend/issues/191)) ([85a7196](https://git.personal.ksv741.ru/WealthLedger/backend/commit/85a71967fa4e85427a991c973c6528206332cf5e))
* share sync reservation join rule (WealthLedger/backend[#119](https://git.personal.ksv741.ru/WealthLedger/backend/issues/119)) ([#162](https://git.personal.ksv741.ru/WealthLedger/backend/issues/162)) ([fa0a6e5](https://git.personal.ksv741.ru/WealthLedger/backend/commit/fa0a6e56030bb1c0302cd7588d30e7fa91e19c11))
* share tbank-sync claim and generation lock (WealthLedger/backend[#166](https://git.personal.ksv741.ru/WealthLedger/backend/issues/166)) ([#188](https://git.personal.ksv741.ru/WealthLedger/backend/issues/188)) ([5d31b3e](https://git.personal.ksv741.ru/WealthLedger/backend/commit/5d31b3e6dd37d1c79b61c43ed820947b5b38aaa1))
* single bank operation projection and domain rules (WealthLedger/backend[#168](https://git.personal.ksv741.ru/WealthLedger/backend/issues/168)) ([#189](https://git.personal.ksv741.ru/WealthLedger/backend/issues/189)) ([deda7ff](https://git.personal.ksv741.ru/WealthLedger/backend/commit/deda7ffc2cc8185eba48a18c37272c404a3e9983))
* split brokerage operations repository and cursor codec (WealthLedger/backend[#167](https://git.personal.ksv741.ru/WealthLedger/backend/issues/167)) ([#190](https://git.personal.ksv741.ru/WealthLedger/backend/issues/190)) ([cd78b43](https://git.personal.ksv741.ru/WealthLedger/backend/commit/cd78b43783cb7469e44b9531d165603df258b55e))
### CI
* drop stale npm audit advisory ignore (WealthLedger/backend[#158](https://git.personal.ksv741.ru/WealthLedger/backend/issues/158)) ([#159](https://git.personal.ksv741.ru/WealthLedger/backend/issues/159)) ([1a5de9b](https://git.personal.ksv741.ru/WealthLedger/backend/commit/1a5de9bebd060a5ff2ca188ad862c4ec5aa06ffd))
### Maintenance
* **deps:** set Renovate release age, PR limit and update groups (WealthLedger/main[#6](https://git.personal.ksv741.ru/WealthLedger/backend/issues/6)) ([#160](https://git.personal.ksv741.ru/WealthLedger/backend/issues/160)) ([bade5fa](https://git.personal.ksv741.ru/WealthLedger/backend/commit/bade5fae1c3170b38717b26eda56ca20183f4ec8))
## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01) ## [2.4.0](https://git.personal.ksv741.ru/WealthLedger/backend/compare/v2.3.1...v2.4.0) (2026-11-01)
### Features ### Features
-13
View File
@@ -53,11 +53,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/instruments/application/catalog-discovery.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 1
}
},
"src/modules/investments/instruments/application/catalog-enrichment.service.ts": { "src/modules/investments/instruments/application/catalog-enrichment.service.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
@@ -135,11 +130,6 @@
"count": 1 "count": 1
} }
}, },
"src/modules/investments/tbank-sync/tbank-sync.service.ts": {
"sonarjs/no-nested-conditional": {
"count": 6
}
},
"test/bank-cards-durability.cjs": { "test/bank-cards-durability.cjs": {
"sonarjs/no-nested-template-literals": { "sonarjs/no-nested-template-literals": {
"count": 1 "count": 1
@@ -158,9 +148,6 @@
"test/tbank-ledger-local.e2e-spec.ts": { "test/tbank-ledger-local.e2e-spec.ts": {
"sonarjs/no-nested-conditional": { "sonarjs/no-nested-conditional": {
"count": 1 "count": 1
},
"sonarjs/no-nested-template-literals": {
"count": 3
} }
} }
} }
+23 -15
View File
@@ -15,6 +15,18 @@ import globals from 'globals';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
const tests = ['**/*.spec.ts', 'test/**/*.ts']; const tests = ['**/*.spec.ts', 'test/**/*.ts'];
const guardedModules = [
{
name: 'tbank-sync',
except: [
'./tbank-sync.api.ts',
'./tbank-sync.dto.ts',
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
},
{ name: 'upcoming-events', except: ['./upcoming-events.api.ts', './upcoming-events.module.ts'] },
];
const prismaImports = [ const prismaImports = [
'@prisma/*', '**/generated/prisma/**', '**/prisma.service', '@prisma/*', '**/generated/prisma/**', '**/prisma.service',
]; ];
@@ -213,24 +225,20 @@ export default defineConfig(
'@typescript-eslint/no-unsafe-enum-comparison': 'error', '@typescript-eslint/no-unsafe-enum-comparison': 'error',
}, },
}, },
{ // Separate rule so it does not replace layer-specific no-restricted-imports configs.
files: ['src/**/*.ts'], // A module may import its own internals, so each guarded module gets a block without its zone.
ignores: [...tests, 'src/modules/investments/tbank-sync/**'], ...[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: { rules: {
// Separate rule so it does not replace layer-specific no-restricted-imports configs.
'import-x/no-restricted-paths': ['error', { 'import-x/no-restricted-paths': ['error', {
zones: [{ zones: guardedModules.filter((module) => module !== own).map(({ except, name }) => ({
target: './src', target: './src',
from: './src/modules/investments/tbank-sync', from: `./src/modules/investments/${name}`,
except: [ except,
'./tbank-sync.api.ts', message: `Use ${name}.api for cross-module access.`,
'./tbank-sync.dto.ts', })),
'./tbank-sync.module.ts',
'./tbank-sync-repository.module.ts',
],
message: 'Use tbank-sync.api for cross-module access.',
}],
}], }],
}, },
}, })),
); );
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.4.0", "version": "2.4.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.4.0", "version": "2.4.1",
"license": "UNLICENSED", "license": "UNLICENSED",
"dependencies": { "dependencies": {
"@grpc/grpc-js": "1.14.6", "@grpc/grpc-js": "1.14.6",
+17 -3
View File
@@ -1,9 +1,19 @@
{ {
"name": "wealth-ledger-backend", "name": "wealth-ledger-backend",
"version": "2.4.0", "version": "2.4.1",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
"imports": {
"#generated/*": {
"development": "./dist-dev/generated/*",
"default": "./dist/generated/*"
},
"#src/*": {
"development": "./dist-dev/src/*",
"default": "./dist/src/*"
}
},
"license": "UNLICENSED", "license": "UNLICENSED",
"engines": { "engines": {
"node": "v24.22.1" "node": "v24.22.1"
@@ -14,8 +24,8 @@
"openapi:check": "npm run build && node scripts/generate-openapi.cjs --check", "openapi:check": "npm run build && node scripts/generate-openapi.cjs --check",
"format": "npm run lint:fix", "format": "npm run lint:fix",
"start": "nest start", "start": "nest start",
"start:dev": "NODE_ENV=development nest start --watch --path tsconfig.dev.json", "start:dev": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --watch --path tsconfig.dev.json",
"start:debug": "NODE_ENV=development nest start --debug --watch --path tsconfig.dev.json", "start:debug": "NODE_ENV=development NODE_OPTIONS=--conditions=development nest start --debug --watch --path tsconfig.dev.json",
"start:prod": "node dist/src/main.js", "start:prod": "node dist/src/main.js",
"lint": "npm run lint:check", "lint": "npm run lint:check",
"lint:check": "eslint . --max-warnings=0", "lint:check": "eslint . --max-warnings=0",
@@ -131,6 +141,10 @@
} }
}, },
"jest": { "jest": {
"moduleNameMapper": {
"^#generated/(.*)$": "<rootDir>/../generated/$1",
"^#src/(.*)$": "<rootDir>/$1"
},
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",
"json", "json",
@@ -5,7 +5,8 @@ import { isDeepStrictEqual } from 'node:util';
import type { import type {
TbankInstrumentLookup as MetadataLookupRow, TbankInstrumentLookup as MetadataLookupRow,
TbankInstrument as MetadataRow, TbankInstrument as MetadataRow,
} from '../../../generated/prisma/client'; } from '#generated/prisma/client';
import type { TbankInstrument } from './tbank.client'; import type { TbankInstrument } from './tbank.client';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
+2 -1
View File
@@ -1,4 +1,5 @@
import type { Prisma } from '../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import type { TbankSyncAttempt } from './tbank-access.service'; import type { TbankSyncAttempt } from './tbank-access.service';
const NO_UPDATED_ROWS = 0; const NO_UPDATED_ROWS = 0;
@@ -9,7 +9,7 @@ jest.mock('bcrypt', () => ({
compare: jest.fn(() => Promise.resolve(true)), compare: jest.fn(() => Promise.resolve(true)),
})); }));
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
+3 -2
View File
@@ -7,9 +7,10 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcrypt'; import * as bcrypt from 'bcrypt';
import { createHash, randomUUID } from 'crypto'; import { createHash, randomUUID } from 'crypto';
import type { Session, User } from '../../../../generated/prisma/client'; import type { Session, User } from '#generated/prisma/client';
import { HttpErrorCode } from '#src/common/error-code';
import { HttpErrorCode } from '../../../common/error-code';
import { AuthRepository } from '../infrastructure/auth.repository'; import { AuthRepository } from '../infrastructure/auth.repository';
import { AuthErrorCode } from './auth-error-code'; import { AuthErrorCode } from './auth-error-code';
import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types'; import { AuthenticationResult, AuthUser, RefreshPayload } from './auth.types';
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
const EXPECTED_ROTATED_SESSIONS = 1; const EXPECTED_ROTATED_SESSIONS = 1;
@@ -3,8 +3,9 @@ import { ConfigService } from '@nestjs/config';
import { PassportStrategy } from '@nestjs/passport'; import { PassportStrategy } from '@nestjs/passport';
import { ExtractJwt, Strategy } from 'passport-jwt'; import { ExtractJwt, Strategy } from 'passport-jwt';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { Environment } from '../../../config/env.validation'; import { Environment } from '#src/config/env.validation';
import { AccessPayload } from '../application/auth.types'; import { AccessPayload } from '../application/auth.types';
@Injectable() @Injectable()
@@ -20,8 +20,9 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { AuthService, REFRESH_TTL_MS } from '../application/auth.service'; import { AuthService, REFRESH_TTL_MS } from '../application/auth.service';
import { AccessPayload, AuthenticationResult } from '../application/auth.types'; import { AccessPayload, AuthenticationResult } from '../application/auth.types';
import { LoginDto, RegisterDto } from './auth.dto'; import { LoginDto, RegisterDto } from './auth.dto';
@@ -5,7 +5,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { import {
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
MAX_OPERATION_DESCRIPTION_LENGTH, MAX_OPERATION_DESCRIPTION_LENGTH,
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { BankCard, Prisma } from '../../../../../generated/prisma/client'; import type { BankCard, Prisma } from '#generated/prisma/client';
import { bankBalance } from '../domain/bank-card.calculations'; import { bankBalance } from '../domain/bank-card.calculations';
import { rubMoney } from '../domain/rub-money'; import { rubMoney } from '../domain/rub-money';
@@ -3,7 +3,7 @@ import { GoneException, Injectable } from '@nestjs/common';
import type { import type {
BankCardOperation, BankCardOperation,
Prisma, Prisma,
} from '../../../../../generated/prisma/client'; } from '#generated/prisma/client';
import { FIRST_PAGE } from '../domain/bank-card-limits'; import { FIRST_PAGE } from '../domain/bank-card-limits';
import { BankOperationKind } from '../domain/bank-operation-kind'; import { BankOperationKind } from '../domain/bank-operation-kind';
@@ -1,8 +1,9 @@
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('../../../../../generated/prisma/client', () => ({ Prisma: {} })); jest.mock('#generated/prisma/client', () => ({ Prisma: {} }));
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { BankCardsRepository } from '../infrastructure/bank-cards.repository'; import { BankCardsRepository } from '../infrastructure/bank-cards.repository';
import { GetBankSummaryUseCase } from './get-bank-summary.use-case'; import { GetBankSummaryUseCase } from './get-bank-summary.use-case';
@@ -1,4 +1,5 @@
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { BankOperationKind } from './bank-operation-kind'; import { BankOperationKind } from './bank-operation-kind';
import { ZERO_KOPECKS } from './rub-money'; import { ZERO_KOPECKS } from './rub-money';
@@ -1,6 +1,6 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '#src/common/money.types';
import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '../../../../common/decimal'; import { DECIMAL_RADIX, decimalFromString, formatDecimal } from '#src/common/decimal';
export const ZERO_KOPECKS = 0n; export const ZERO_KOPECKS = 0n;
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { BankCard, BankCardOperation, Prisma } from '../../../../../generated/prisma/client'; import { BankCard, BankCardOperation, Prisma } from '#generated/prisma/client';
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code'; import { isPrismaErrorCode, PrismaErrorCode } from '#src/prisma/prisma-error-code';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { import {
BankErrorCode, BankErrorCode,
conflict, conflict,
@@ -24,7 +24,8 @@ import {
ValidateNested, ValidateNested,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
FIRST_PAGE, FIRST_PAGE,
MAX_CARD_NAME_LENGTH, MAX_CARD_NAME_LENGTH,
@@ -29,8 +29,9 @@ import {
import { import {
ApiProblemResponses, ApiProblemResponses,
ProblemDetailsDto, ProblemDetailsDto,
} from '../../../../common/decorators/api-safe-error-response.decorator'; } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { BankCardsService } from '../application/bank-cards.service'; import { BankCardsService } from '../application/bank-cards.service';
import { BankOperationsService } from '../application/bank-operations.service'; import { BankOperationsService } from '../application/bank-operations.service';
import { ExpenseCategoriesService } from '../application/expense-categories.service'; import { ExpenseCategoriesService } from '../application/expense-categories.service';
@@ -1,6 +1,7 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { InvestmentResourceMetaDto } from '../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../investment-resource.dto';
import { PortfolioStructureDto } from '../portfolio/portfolio.api'; import { PortfolioStructureDto } from '../portfolio/portfolio.api';
import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto'; import { HourlySyncStatusDto } from '../tbank-sync/tbank-sync.dto';
@@ -3,7 +3,8 @@ import type { Request } from 'express';
import { Controller, Get, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { BrokerageAccountsResourceDto } from './brokerage-account.dto'; import { BrokerageAccountsResourceDto } from './brokerage-account.dto';
import { BrokerageAccountsService } from './brokerage-accounts.service'; import { BrokerageAccountsService } from './brokerage-accounts.service';
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
@@ -1,4 +1,4 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
@@ -7,7 +7,8 @@ import {
formatDecimal, formatDecimal,
isZero, isZero,
multiply, multiply,
} from '../../../common/decimal'; } from '#src/common/decimal';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals'; import { sumMoneyByCurrency } from '../portfolio/domain/portfolio-totals';
import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioPositionDto, PortfolioService } from '../portfolio/portfolio.api';
@@ -3,9 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { InvestmentReadQueryDto } from '../investment-resource.dto';
import { import {
IncomeAnalyticsQueryDto, IncomeAnalyticsQueryDto,
IncomeAnalyticsResourceDto, IncomeAnalyticsResourceDto,
@@ -25,12 +25,10 @@ export class IncomeAnalyticsController {
@ApiOkResponse({ type: IncomeSummaryResourceDto }) @ApiOkResponse({ type: IncomeSummaryResourceDto })
allSummary( allSummary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
'all', 'all',
query.sync === 'observe',
); );
} }
@@ -45,7 +43,6 @@ export class IncomeAnalyticsController {
'all', 'all',
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
@@ -54,12 +51,10 @@ export class IncomeAnalyticsController {
summary( summary(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('accountId') accountId: string, @Param('accountId') accountId: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.summary( return this.income.summary(
request.user.sub, request.user.sub,
accountId, accountId,
query.sync === 'observe',
); );
} }
@@ -75,7 +70,6 @@ export class IncomeAnalyticsController {
accountId, accountId,
query.from, query.from,
query.to, query.to,
query.sync === 'observe',
); );
} }
} }
@@ -1,17 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsOptional, Matches } from 'class-validator'; import { IsOptional, Matches } from 'class-validator';
import { MoneyDto } from '../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
InvestmentDependencyDto, InvestmentDependencyDto,
InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../investment-resource.dto'; } from '../investment-resource.dto';
import { BrokerageOperationDto } from '../operations/operations.api'; import { BrokerageOperationDto } from '../operations/operations.api';
const dateOnly = /^\d{4}-\d{2}-\d{2}$/; const dateOnly = /^\d{4}-\d{2}-\d{2}$/;
export class IncomeAnalyticsQueryDto extends InvestmentReadQueryDto { export class IncomeAnalyticsQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
format: 'date', format: 'date',
description: description:
@@ -1,4 +1,4 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
jest.mock('../operations/application/brokerage-operations.service', () => ({ jest.mock('../operations/application/brokerage-operations.service', () => ({
@@ -6,8 +6,9 @@ import {
compare, compare,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
} from '../../../common/decimal'; } from '#src/common/decimal';
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol'; import { isTbankProtocolValue, TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { DateOnlyRange, moscowDate } from '../investment-calendar'; import { DateOnlyRange, moscowDate } from '../investment-calendar';
import { investmentResourceMeta } from '../investment-resource.mapper'; import { investmentResourceMeta } from '../investment-resource.mapper';
import { import {
@@ -43,14 +44,12 @@ export class IncomeAnalyticsService {
async summary( async summary(
userId: string, userId: string,
accountId: string, accountId: string,
observe = false,
): Promise<IncomeSummaryResourceDto> { ): Promise<IncomeSummaryResourceDto> {
const plan = planIncomePeriod(undefined, undefined); const plan = planIncomePeriod(undefined, undefined);
const [selected] = await this.operations.collectRanges( const [selected] = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
() => [plan.selected], () => [plan.selected],
observe,
); );
if (selected.items === null) return { data: null, meta: selected.meta }; if (selected.items === null) return { data: null, meta: selected.meta };
assert(selected.meta.lastSuccessfulAt); assert(selected.meta.lastSuccessfulAt);
@@ -70,14 +69,12 @@ export class IncomeAnalyticsService {
accountId: string, accountId: string,
from: string | undefined, from: string | undefined,
to: string | undefined, to: string | undefined,
observe = false,
): Promise<IncomeAnalyticsResourceDto> { ): Promise<IncomeAnalyticsResourceDto> {
const plan = planIncomePeriod(from, to); const plan = planIncomePeriod(from, to);
const [current] = await this.operations.collectRanges( const [current] = await this.operations.collectRanges(
userId, userId,
accountId, accountId,
() => [plan.selected], () => [plan.selected],
observe,
); );
const dependencies: IncomeDependencyDto[] = [ const dependencies: IncomeDependencyDto[] = [
{ ...current.meta, role: 'selected' }, { ...current.meta, role: 'selected' },
@@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_DAY } from '#src/common/time';
import { import {
DateOnlyRange, DateOnlyRange,
dateParts, dateParts,
@@ -4,7 +4,7 @@ jest.mock('../infrastructure/candle.repository', () => ({
jest.mock('./catalog-enrichment.service', () => ({ jest.mock('./catalog-enrichment.service', () => ({
CatalogEnrichmentService: class {}, CatalogEnrichmentService: class {},
})); }));
jest.mock('../../../../integrations/tbank/tbank.api', () => ({ jest.mock('#src/integrations/tbank/tbank.api', () => ({
TbankAccessService: class {}, TbankAccessService: class {},
TbankClient: class {}, TbankClient: class {},
})); }));
@@ -5,15 +5,16 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '#src/common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
type TbankShare, type TbankShare,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
candleGaps, candleGaps,
@@ -98,7 +99,7 @@ export class CandleService {
private readonly logger: StructuredLogger, private readonly logger: StructuredLogger,
) {} ) {}
async read(userId: string, input: CandleInput, observe = false) { async read(userId: string, input: CandleInput) {
const { revision } = await this.access.local(userId); const { revision } = await this.access.local(userId);
const [detail] = await this.sources.readMany( const [detail] = await this.sources.readMany(
userId, userId,
@@ -118,7 +119,7 @@ export class CandleService {
const hasCompleteRange = Boolean( const hasCompleteRange = Boolean(
range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS, range && saved && candleGaps(range, saved.coverage, now).length === NO_CANDLE_GAPS,
); );
if (!observe && (!saved || !hasCompleteRange)) { if (!saved || !hasCompleteRange) {
await this.request(userId, revision, input, now); await this.request(userId, revision, input, now);
} }
const work = await this.repository.work(userId, revision, scope(input)); const work = await this.repository.work(userId, revision, scope(input));
@@ -8,16 +8,15 @@ import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { InstrumentErrorCode } from '../domain/instrument-error-code'; import { InstrumentErrorCode } from '../domain/instrument-error-code';
import { import {
CatalogDiscoveryRepository,
catalogQuery, catalogQuery,
type CatalogQuery, type CatalogQuery,
catalogScope, catalogScope,
} from '../infrastructure/catalog-discovery.repository'; } from '../infrastructure/catalog-discovery.repository';
import { CatalogEnrichmentService } from './catalog-enrichment.service';
import { import {
catalogSearchLimit, catalogSearchLimit,
selectCatalogInstruments, selectCatalogInstruments,
@@ -25,7 +24,6 @@ import {
import { publicInstrument } from './public-instrument'; import { publicInstrument } from './public-instrument';
const UNAMBIGUOUS_MATCH_COUNT = 1; const UNAMBIGUOUS_MATCH_COUNT = 1;
const NO_MATCHES = 0;
const FIRST_RESULT_OFFSET = 0; const FIRST_RESULT_OFFSET = 0;
const tickerAmbiguityLimit = 2; const tickerAmbiguityLimit = 2;
@@ -33,203 +31,56 @@ const tickerAmbiguityLimit = 2;
@Injectable() @Injectable()
export class CatalogDiscoveryService { export class CatalogDiscoveryService {
constructor( constructor(
private readonly repository: CatalogDiscoveryRepository,
private readonly access: TbankAccessService, private readonly access: TbankAccessService,
private readonly tbank: TbankClient, private readonly tbank: TbankClient,
private readonly enrichment: CatalogEnrichmentService,
) {} ) {}
async read(userId: string, input: CatalogQuery) { async search(userId: string, input: CatalogQuery) {
input = catalogQuery(input); return this.onlineSearch(userId, catalogQuery(input));
const connection = await this.access.local(userId);
const saved = await this.repository.read(input);
return {
data: saved.data,
meta: investmentResourceMeta({
resourceKey: catalogScope(input),
dataRevision: saved.dataRevision,
availability: saved.data === null ? 'missing' : 'complete',
fetchedAt: saved.fetchedAt,
freshForMs: Number.MAX_SAFE_INTEGER,
work: await this.repository.work(userId, connection.revision, input),
}),
};
}
async search(userId: string, input: CatalogQuery, observe = false) {
if (!observe) return this.onlineSearch(userId, catalogQuery(input));
const discovery = await this.read(userId, input);
const sources = discovery.data
? await this.enrichment.readMany(
userId,
input.instrumentType,
discovery.data.map((item) => item.uid),
observe,
)
: [];
const dependencies = [
discovery.meta,
...sources.map((source) => source.meta),
];
return {
data:
discovery.data?.map((short) => ({
...short,
detail:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'detail',
)?.instrument ?? null,
price:
sources.find(
(source) =>
source.input.uid === short.uid &&
source.input.kind === 'last-price',
)?.price ?? null,
})) ?? null,
meta: investmentResourceMeta({
resourceKey: `search:${catalogScope(catalogQuery(input))}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability:
discovery.data === null
? 'missing'
: sources.every((source) => source.meta.completeness === 'complete')
? 'complete'
: 'partial',
fetchedAt: discovery.meta.lastSuccessfulAt
? new Date(discovery.meta.lastSuccessfulAt)
: null,
freshForMs: Number.MAX_SAFE_INTEGER,
work: discovery.meta.sync.id
? {
id: discovery.meta.sync.id,
state: discovery.meta.sync.state,
errorCode: discovery.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
} }
async detail( async detail(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
uid: string, uid: string,
observe = false,
) { ) {
if (!observe) { const instrument = await this.access.online(userId, ({ token }) =>
const instrument = await this.access.online(userId, ({ token }) => instrumentType === 'share'
instrumentType === 'share' ? this.tbank.getShare(token, uid)
? this.tbank.getShare(token, uid) : this.tbank.getBond(token, uid),
: this.tbank.getBond(token, uid),
);
if (!instrument) throw new NotFoundException();
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 source = sources[0]; if (!instrument) throw new NotFoundException();
if (source.meta.completeness === 'complete' && !source.instrument) {
throw new NotFoundException();
}
const instrument = source.instrument;
let slug = uid;
const dependencies = [source.meta];
if (
instrument?.ticker &&
/^[A-Z0-9][\w.@+-]{0,79}$/i.test(instrument.ticker)
) {
const alias = await this.read(userId, {
instrumentType,
query: instrument.ticker,
exactTicker: true,
});
if (alias.data?.length === UNAMBIGUOUS_MATCH_COUNT && alias.data[0].uid === uid) {
slug = instrument.ticker.toUpperCase();
}
dependencies.push(alias.meta);
}
return { return {
data: instrument ? { ...instrument, uid, slug } : null, data: { ...publicInstrument({ ...instrument, uid }), uid, slug: instrument.ticker?.toUpperCase() ?? uid },
meta: investmentResourceMeta({ meta: investmentResourceMeta({
resourceKey: `${instrumentType}/detail/${uid}`, resourceKey: `${instrumentType}/detail/${uid}`,
dataRevision: dependencies dataRevision: `online:${Date.now()}`,
.map((dependency) => dependency.dataRevision) availability: 'complete',
.join('/'), fetchedAt: new Date(),
availability: source.meta.availability,
fetchedAt: source.meta.lastSuccessfulAt
? new Date(source.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio, freshForMs: tbankSourceTtlMs.portfolio,
work: source.meta.sync.id work: null,
? {
id: source.meta.sync.id,
state: source.meta.sync.state,
errorCode: source.meta.sync.errorCode,
}
: null,
dependencies,
}), }),
}; };
} }
// eslint-disable-next-line sonarjs/cognitive-complexity -- Online and observe paths intentionally preserve distinct error semantics.
async byTicker( async byTicker(
userId: string, userId: string,
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
ticker: string, ticker: string,
observe = false,
) { ) {
if (!observe) { const query = ticker.trim().toUpperCase();
const query = ticker.trim().toUpperCase(); const found = await this.access.online(userId, ({ token }) =>
const found = await this.access.online(userId, ({ token }) => instrumentType === 'share'
instrumentType === 'share' ? this.tbank.findShares(token, query)
? this.tbank.findShares(token, query) : this.tbank.findBonds(token, query),
: this.tbank.findBonds(token, query), );
); const matches = selectCatalogInstruments(
const matches = selectCatalogInstruments( found.filter((item) => item.ticker?.trim().toUpperCase() === query),
found.filter((item) => item.ticker?.trim().toUpperCase() === query), 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, {
instrumentType, instrumentType,
query: ticker, ).slice(FIRST_RESULT_OFFSET, tickerAmbiguityLimit);
exactTicker: true, if (!matches.length) throw new NotFoundException();
}); if (matches.length > UNAMBIGUOUS_MATCH_COUNT) {
if (lookup.data?.length === NO_MATCHES) throw new NotFoundException();
if (lookup.data && lookup.data.length > UNAMBIGUOUS_MATCH_COUNT) {
throw new ConflictException({ throw new ConflictException({
code: code:
instrumentType === 'share' instrumentType === 'share'
@@ -237,34 +88,8 @@ export class CatalogDiscoveryService {
: InstrumentErrorCode.BondTickerAmbiguous, : InstrumentErrorCode.BondTickerAmbiguous,
}); });
} }
const result = lookup.data const result = await this.detail(userId, instrumentType, matches[0].uid);
? await this.detail(userId, instrumentType, lookup.data[0].uid, observe) return { ...result, data: result.data ? { ...result.data, slug: query } : null };
: null;
const dependencies = [lookup.meta, ...(result ? [result.meta] : [])];
return {
data: result?.data
? { ...result.data, slug: ticker.trim().toUpperCase() }
: null,
meta: investmentResourceMeta({
resourceKey: `${instrumentType}/ticker/${ticker.trim().toUpperCase()}`,
dataRevision: dependencies
.map((dependency) => dependency.dataRevision)
.join('/'),
availability: result?.meta.availability ?? 'missing',
fetchedAt: lookup.meta.lastSuccessfulAt
? new Date(lookup.meta.lastSuccessfulAt)
: null,
freshForMs: tbankSourceTtlMs.portfolio,
work: lookup.meta.sync.id
? {
id: lookup.meta.sync.id,
state: lookup.meta.sync.state,
errorCode: lookup.meta.sync.errorCode,
}
: null,
dependencies,
}),
};
} }
private async onlineSearch(userId: string, input: CatalogQuery) { private async onlineSearch(userId: string, input: CatalogQuery) {
@@ -1,8 +1,8 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankClient, TbankClient,
@@ -10,7 +10,8 @@ import {
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
tbankSourceTtlMs, tbankSourceTtlMs,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
CatalogDiscoveryRepository, CatalogDiscoveryRepository,
@@ -221,9 +222,8 @@ export class CatalogEnrichmentService {
instrumentType: 'bond' | 'share', instrumentType: 'bond' | 'share',
uid: string, uid: string,
section: 'company' | 'metrics' | 'quote', section: 'company' | 'metrics' | 'quote',
observe = false,
) { ) {
if (section === 'company' && !observe) { if (section === 'company') {
const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => { const { description, fundamentals, instrument } = await this.access.online(userId, async ({ token }) => {
const instrument = const instrument =
instrumentType === 'share' instrumentType === 'share'
@@ -253,25 +253,22 @@ export class CatalogEnrichmentService {
observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(), observedAt: meta.lastSuccessfulAt ?? new Date().toISOString(),
}; };
} }
const kinds: CatalogSource['kind'][] = const kinds: CatalogSource['kind'][] = [
section === 'company' ...(instrumentType === 'bond'
? ['detail', 'description', 'fundamentals'] ? ['bond-current' as const]
: [ : ['detail' as const]),
...(instrumentType === 'bond' 'last-price',
? ['bond-current' as const] 'trading-status',
: ['detail' as const]), 'comparison',
'last-price', ...(section === 'metrics'
'trading-status', ? ['coupons' as const, 'yield' as const, 'rating' as const]
'comparison', : []),
...(section === 'metrics' ];
? ['coupons' as const, 'yield' as const, 'rating' as const]
: []),
];
const sources = await this.readMany( const sources = await this.readMany(
userId, userId,
instrumentType, instrumentType,
[uid], [uid],
observe, false,
kinds, kinds,
); );
const first = sources[0]; const first = sources[0];
@@ -299,7 +296,7 @@ export class CatalogEnrichmentService {
fetchedAt: first.meta.lastSuccessfulAt fetchedAt: first.meta.lastSuccessfulAt
? new Date(first.meta.lastSuccessfulAt) ? new Date(first.meta.lastSuccessfulAt)
: null, : null,
freshForMs: section === 'company' ? Number.MAX_SAFE_INTEGER : tbankSourceTtlMs.quote, freshForMs: tbankSourceTtlMs.quote,
work: null, work: null,
dependencies, dependencies,
}); });
@@ -1,6 +1,6 @@
import type { TbankShare } from '../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol'; import { TbankInstrumentType } from '#src/integrations/tbank/tbank-protocol';
export const catalogSearchLimit = 30; export const catalogSearchLimit = 30;
@@ -3,13 +3,13 @@ import { Injectable } from '@nestjs/common';
import { import {
decimalFromString, decimalFromString,
divide, divide,
} from '../../../../common/decimal'; } from '#src/common/decimal';
import { import {
MILLISECONDS_PER_DAY, MILLISECONDS_PER_DAY,
MILLISECONDS_PER_SECOND, MILLISECONDS_PER_SECOND,
} from '../../../../common/time'; } from '#src/common/time';
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api'; import { type MoexBondEvent, MoexClient } from '#src/integrations/moex/moex.api';
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api'; import { NcrRatingsClient } from '#src/integrations/ncr/ncr.api';
import { import {
type TbankBond, type TbankBond,
type TbankCandle, type TbankCandle,
@@ -20,7 +20,8 @@ import {
type TbankLastPrice, type TbankLastPrice,
type TbankShare, type TbankShare,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { candleGaps, utcDay } from '../domain/candle-coverage'; import { candleGaps, utcDay } from '../domain/candle-coverage';
import { CandleRepository } from '../infrastructure/candle.repository'; import { CandleRepository } from '../infrastructure/candle.repository';
import { import {
@@ -1,9 +1,9 @@
import type { import type {
TbankCandle, TbankCandle,
TbankTimestamp, TbankTimestamp,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '#src/common/time';
const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0; const MIN_TIMESTAMP_SECONDS_EXCLUSIVE = 0;
@@ -4,8 +4,9 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../common/decimal'; } from '#src/common/decimal';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
PortfolioDto, PortfolioDto,
PortfolioPositionDto, PortfolioPositionDto,
@@ -1,7 +1,7 @@
import type { import type {
TbankBond, TbankBond,
TbankShare, TbankShare,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { publicInstrument } from './public-instrument'; import { publicInstrument } from './public-instrument';
@@ -1,7 +1,7 @@
import type { import type {
TbankBond, TbankBond,
TbankShare, TbankShare,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
export function publicInstrument(value: TbankBond | TbankShare) { export function publicInstrument(value: TbankBond | TbankShare) {
const bond = value as TbankBond; const bond = value as TbankBond;
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
@@ -14,7 +15,8 @@ import {
type IdentifiedBond = TbankBond & { uid: string }; type IdentifiedBond = TbankBond & { uid: string };
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { money } from '../infrastructure/bond-money.mapper'; import { money } from '../infrastructure/bond-money.mapper';
const MIN_LOT_EXCLUSIVE = 0; const MIN_LOT_EXCLUSIVE = 0;
@@ -25,12 +27,10 @@ export class BondCatalogService {
async search( async search(
userId: string, userId: string,
query: string, query: string,
observe = false,
): Promise<BondSearchResourceDto> { ): Promise<BondSearchResourceDto> {
const result = await this.discovery.search( const result = await this.discovery.search(
userId, userId,
{ instrumentType: 'bond', query }, { instrumentType: 'bond', query },
observe,
); );
return { return {
...result, ...result,
@@ -58,9 +58,8 @@ export class BondCatalogService {
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.detail(userId, 'bond', uid, observe); const result = await this.discovery.detail(userId, 'bond', uid);
return { return {
...result, ...result,
data: result.data data: result.data
@@ -72,13 +71,11 @@ export class BondCatalogService {
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
observe = false,
): Promise<BondResourceDto> { ): Promise<BondResourceDto> {
const result = await this.discovery.byTicker( const result = await this.discovery.byTicker(
userId, userId,
'bond', 'bond',
ticker, ticker,
observe,
); );
return { return {
...result, ...result,
@@ -5,19 +5,20 @@ import {
divide, divide,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol'; import { isTbankProtocolValue, TbankCouponType } from '#src/integrations/tbank/tbank-protocol';
import { import {
TbankAccessService, TbankAccessService,
type TbankBond, type TbankBond,
type TbankCoupon, type TbankCoupon,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
PortfolioService, PortfolioService,
} from '../../../portfolio/portfolio.api'; } from '#src/modules/investments/portfolio/portfolio.api';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
type CatalogPayload, type CatalogPayload,
@@ -54,14 +55,12 @@ export class BondIncomeService {
async metrics( async metrics(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondMetricsResourceDto> { ): Promise<BondMetricsResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'metrics', 'metrics',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -82,7 +81,6 @@ export class BondIncomeService {
async coupons( async coupons(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondCouponsResourceDto> { ): Promise<BondCouponsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
@@ -93,7 +91,7 @@ export class BondIncomeService {
userId, userId,
'bond', 'bond',
[uid], [uid],
observe, false,
['detail', 'coupons'], ['detail', 'coupons'],
{ from, to }, { from, to },
); );
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankBond } from '../../../../../integrations/tbank/tbank.api'; import type { TbankBond } from '#src/integrations/tbank/tbank.api';
import { import {
add, add,
@@ -9,9 +9,10 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -38,14 +39,12 @@ export class BondMarketService {
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<BondQuoteResourceDto> { ): Promise<BondQuoteResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'bond', 'bond',
uid, uid,
'quote', 'quote',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -59,12 +58,10 @@ export class BondMarketService {
userId: string, userId: string,
uid: string, uid: string,
period: BondCandlesQueryDto['period'] = 'month', period: BondCandlesQueryDto['period'] = 'month',
observe = false,
) { ) {
return this.history.read( return this.history.read(
userId, userId,
{ uid, instrumentType: 'bond', period }, { uid, instrumentType: 'bond', period },
observe,
); );
} }
} }
@@ -1,9 +1,9 @@
import type { PortfolioDto } from '../../../portfolio/portfolio.api'; import type { PortfolioDto } from '#src/modules/investments/portfolio/portfolio.api';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
import { bondCoupons, bondMetrics } from './bond-income.service'; import { bondCoupons, bondMetrics } from './bond-income.service';
import { bondQuote } from './bond-market.service'; import { bondQuote } from './bond-market.service';
jest.mock('../../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1,6 +1,6 @@
import { formatDecimal } from '../../../../../common/decimal'; import { formatDecimal } from '#src/common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { type TbankMoney } from '../../../../../integrations/tbank/tbank.api'; import { type TbankMoney } from '#src/integrations/tbank/tbank.api';
export function money(value?: TbankMoney) { export function money(value?: TbankMoney) {
const currency = value?.currency?.toUpperCase(); const currency = value?.currency?.toUpperCase();
@@ -11,9 +11,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { BondCatalogService } from '../application/bond-catalog.service'; import { BondCatalogService } from '../application/bond-catalog.service';
import { BondIncomeService } from '../application/bond-income.service'; import { BondIncomeService } from '../application/bond-income.service';
import { BondMarketService } from '../application/bond-market.service'; import { BondMarketService } from '../application/bond-market.service';
@@ -47,9 +47,8 @@ export class BondController {
metrics( metrics(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.metrics(request.user.sub, uid, query.sync === 'observe'); return this.income.metrics(request.user.sub, uid);
} }
@Get() @Get()
@@ -61,7 +60,6 @@ export class BondController {
return this.bonds.search( return this.bonds.search(
request.user.sub, request.user.sub,
query.query, query.query,
query.sync === 'observe',
); );
} }
@@ -70,12 +68,10 @@ export class BondController {
getByTicker( getByTicker(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param() params: BondTickerParamsDto, @Param() params: BondTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.bonds.getByTicker( return this.bonds.getByTicker(
request.user.sub, request.user.sub,
params.ticker, params.ticker,
query.sync === 'observe',
); );
} }
@@ -84,9 +80,8 @@ export class BondController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.bonds.get(request.user.sub, uid, query.sync === 'observe'); return this.bonds.get(request.user.sub, uid);
} }
@Get(':uid/quote') @Get(':uid/quote')
@@ -94,9 +89,8 @@ export class BondController {
quote( quote(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe'); return this.market.quote(request.user.sub, uid);
} }
@Get(':uid/candles') @Get(':uid/candles')
@@ -110,7 +104,6 @@ export class BondController {
request.user.sub, request.user.sub,
uid, uid,
query.period, query.period,
query.sync === 'observe',
); );
} }
@@ -128,8 +121,7 @@ export class BondController {
coupons( coupons(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.coupons(request.user.sub, uid, query.sync === 'observe'); return this.income.coupons(request.user.sub, uid);
} }
} }
@@ -1,7 +1,8 @@
import { ApiProperty } from '@nestjs/swagger'; import { ApiProperty } from '@nestjs/swagger';
import { MoneyDto } from '../../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { InvestmentResourceMetaDto } from '../../../investment-resource.dto'; import { InvestmentResourceMetaDto } from '#src/modules/investments/investment-resource.dto';
import { import {
StockCandlesDto, StockCandlesDto,
StockCandlesQueryDto, StockCandlesQueryDto,
@@ -1,4 +1,4 @@
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '#src/common/time';
const FIRST_RANGE_END_INDEX = 1; const FIRST_RANGE_END_INDEX = 1;
@@ -1,18 +1,19 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import { import {
claimSyncWork, claimSyncWork,
lockConnectionGeneration, lockConnectionGeneration,
} from '../../../../integrations/tbank/tbank-sync-claim'; } from '#src/integrations/tbank/tbank-sync-claim';
import { import {
isJoinableRunningAttempt, isJoinableRunningAttempt,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '../../../../prisma/to-json'; import { toJson } from '#src/prisma/to-json';
import { import {
type CandleRange, type CandleRange,
confirmedCandleCoverage, confirmedCandleCoverage,
@@ -2,11 +2,11 @@ import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim'; import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api'; import { isJoinableRunningAttempt, type TbankBond, type TbankShare, type TbankSyncAttempt } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '../../../../prisma/to-json'; import { toJson } from '#src/prisma/to-json';
const UNCHANGED_REVISION_INCREMENT = 0; const UNCHANGED_REVISION_INCREMENT = 0;
const REVISION_INCREMENT = 1; const REVISION_INCREMENT = 1;
@@ -1,8 +1,10 @@
import type { TbankCandle } from '../../../../integrations/tbank/tbank.api'; import type { TbankCandle } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import type { SavedCandle } from '../domain/candle-coverage'; import type { SavedCandle } from '../domain/candle-coverage';
import { formatDecimal } from '../../../../common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal';
import { timestamp } from '../application/instrument-market'; import { timestamp } from '../application/instrument-market';
function toSavedCandle(candle: TbankCandle): SavedCandle { function toSavedCandle(candle: TbankCandle): SavedCandle {
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { MoexModule } from '../../../integrations/moex/moex.module'; import { MoexModule } from '#src/integrations/moex/moex.module';
import { NcrModule } from '../../../integrations/ncr/ncr.module'; import { NcrModule } from '#src/integrations/ncr/ncr.module';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { CandleService } from './application/candle.service'; import { CandleService } from './application/candle.service';
import { CatalogDiscoveryService } from './application/catalog-discovery.service'; import { CatalogDiscoveryService } from './application/catalog-discovery.service';
import { CatalogEnrichmentService } from './application/catalog-enrichment.service'; import { CatalogEnrichmentService } from './application/catalog-enrichment.service';
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { PortfolioModule } from '../portfolio/portfolio.module'; import { PortfolioModule } from '../portfolio/portfolio.module';
import { BondCatalogService } from './bonds/application/bond-catalog.service'; import { BondCatalogService } from './bonds/application/bond-catalog.service';
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankFundamentals } from '../../../../../integrations/tbank/tbank.api'; import type { TbankFundamentals } from '#src/integrations/tbank/tbank.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { import {
@@ -13,14 +13,12 @@ export class GetStockCompanyUseCase {
async execute( async execute(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<StockCompanyResourceDto> { ): Promise<StockCompanyResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'share', 'share',
uid, uid,
'company', 'company',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -1,9 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { formatDecimal } from '#src/common/decimal';
import { decimal } from '#src/integrations/tbank/decimal';
import { formatDecimal } from '../../../../../common/decimal';
import { decimal } from '../../../../../integrations/tbank/decimal';
import { CatalogDiscoveryService } from '../../application/catalog-discovery.service'; import { CatalogDiscoveryService } from '../../application/catalog-discovery.service';
import { timestamp } from '../../application/instrument-market'; import { timestamp } from '../../application/instrument-market';
import { import {
@@ -22,12 +23,10 @@ export class StockCatalogService {
async search( async search(
userId: string, userId: string,
query: string, query: string,
observe = false,
): Promise<StockSearchResourceDto> { ): Promise<StockSearchResourceDto> {
const result = await this.discovery.search( const result = await this.discovery.search(
userId, userId,
{ instrumentType: 'share', query }, { instrumentType: 'share', query },
observe,
); );
return { return {
...result, ...result,
@@ -59,9 +58,8 @@ export class StockCatalogService {
async get( async get(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<StockResourceDto> { ): Promise<StockResourceDto> {
const result = await this.discovery.detail(userId, 'share', uid, observe); const result = await this.discovery.detail(userId, 'share', uid);
return { return {
...result, ...result,
data: result.data data: result.data
@@ -73,13 +71,11 @@ export class StockCatalogService {
async getByTicker( async getByTicker(
userId: string, userId: string,
ticker: string, ticker: string,
observe = false,
): Promise<StockResourceDto> { ): Promise<StockResourceDto> {
const result = await this.discovery.byTicker( const result = await this.discovery.byTicker(
userId, userId,
'share', 'share',
ticker, ticker,
observe,
); );
return { return {
...result, ...result,
@@ -4,18 +4,19 @@ import {
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
multiply, multiply,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { import {
TbankAccessService, TbankAccessService,
type TbankDividend, type TbankDividend,
tbankSourceNeedsRefresh, tbankSourceNeedsRefresh,
} from '../../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { import {
ForecastService, ForecastService,
PortfolioService, PortfolioService,
} from '../../../portfolio/portfolio.api'; } from '#src/modules/investments/portfolio/portfolio.api';
import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service'; import { CatalogEnrichmentService } from '../../application/catalog-enrichment.service';
import { moscowDay, timestamp } from '../../application/instrument-market'; import { moscowDay, timestamp } from '../../application/instrument-market';
import { instrumentPosition } from '../../application/instrument-position'; import { instrumentPosition } from '../../application/instrument-position';
@@ -51,7 +52,6 @@ export class StockIncomeService {
async dividends( async dividends(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<StockDividendsResourceDto> { ): Promise<StockDividendsResourceDto> {
const connection = await this.access.local(userId); const connection = await this.access.local(userId);
const today = moscowDay(new Date().toISOString()); const today = moscowDay(new Date().toISOString());
@@ -62,7 +62,7 @@ export class StockIncomeService {
userId, userId,
'share', 'share',
[uid], [uid],
observe, false,
['detail', 'dividends'], ['detail', 'dividends'],
{ from, to }, { from, to },
); );
@@ -6,9 +6,10 @@ import {
formatDecimal, formatDecimal,
multiply, multiply,
subtract, subtract,
} from '../../../../../common/decimal'; } from '#src/common/decimal';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time'; import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import { decimal } from '../../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { CandleService } from '../../application/candle.service'; import { CandleService } from '../../application/candle.service';
import { import {
CatalogEnrichmentService, CatalogEnrichmentService,
@@ -34,14 +35,12 @@ export class StockMarketService {
async quote( async quote(
userId: string, userId: string,
uid: string, uid: string,
observe = false,
): Promise<StockQuoteResourceDto> { ): Promise<StockQuoteResourceDto> {
const result = await this.sources.section( const result = await this.sources.section(
userId, userId,
'share', 'share',
uid, uid,
'quote', 'quote',
observe,
); );
return { return {
data: result.values.instrument data: result.values.instrument
@@ -55,12 +54,10 @@ export class StockMarketService {
userId: string, userId: string,
uid: string, uid: string,
period: StockCandlesQueryDto['period'] = 'month', period: StockCandlesQueryDto['period'] = 'month',
observe = false,
) { ) {
return this.history.read( return this.history.read(
userId, userId,
{ uid, instrumentType: 'share', period }, { uid, instrumentType: 'share', period },
observe,
); );
} }
} }
@@ -1,10 +1,10 @@
jest.mock('../../../../../integrations/tbank/tbank.api', () => ({ jest.mock('#src/integrations/tbank/tbank.api', () => ({
TbankAccessService: class {}, TbankAccessService: class {},
})); }));
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common'; import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { StockScreenerService } from './stock-screener.service'; import { StockScreenerService } from './stock-screener.service';
@@ -6,11 +6,12 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { TbankShare } from '../../../../../integrations/tbank/tbank-market.types'; import type { TbankShare } from '#src/integrations/tbank/tbank-market.types';
import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { TbankAccessService } from '#src/integrations/tbank/tbank.api';
import { TbankClient } from '#src/integrations/tbank/tbank.client';
import { StructuredLogger } from '../../../../../common/logging/structured-logger.service';
import { TbankAccessService } from '../../../../../integrations/tbank/tbank.api';
import { TbankClient } from '../../../../../integrations/tbank/tbank.client';
import { import {
selectStockScreenerInstruments, selectStockScreenerInstruments,
type StockScreenerConditions, type StockScreenerConditions,
@@ -2,7 +2,7 @@ import { previousClose } from '../../application/instrument-market';
import { metrics } from './get-stock-company.use-case'; import { metrics } from './get-stock-company.use-case';
import { stockDividends } from './stock-income.service'; import { stockDividends } from './stock-income.service';
import { stockQuote } from './stock-market.service'; import { stockQuote } from './stock-market.service';
jest.mock('../../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) }); const time = (value: string) => ({ seconds: String(Date.parse(value) / 1000) });
@@ -1,4 +1,4 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
import { selectStockScreenerInstruments } from './stock-screener-selection.rules'; import { selectStockScreenerInstruments } from './stock-screener-selection.rules';
@@ -1,4 +1,4 @@
import type { TbankShare } from '../../../../../integrations/tbank/tbank.api'; import type { TbankShare } from '#src/integrations/tbank/tbank.api';
export type StockScreenerCatalog = { export type StockScreenerCatalog = {
asOf: Date; asOf: Date;
@@ -17,9 +17,9 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { InvestmentReadQueryDto } from '../../../investment-resource.dto';
import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case'; import { GetStockCompanyUseCase } from '../application/get-stock-company.use-case';
import { StockCatalogService } from '../application/stock-catalog.service'; import { StockCatalogService } from '../application/stock-catalog.service';
import { StockIncomeService } from '../application/stock-income.service'; import { StockIncomeService } from '../application/stock-income.service';
@@ -64,7 +64,6 @@ export class StockController {
return this.stocks.search( return this.stocks.search(
request.user.sub, request.user.sub,
query.query, query.query,
query.sync === 'observe',
); );
} }
@@ -73,12 +72,10 @@ export class StockController {
getByTicker( getByTicker(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param() params: StockTickerParamsDto, @Param() params: StockTickerParamsDto,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.stocks.getByTicker( return this.stocks.getByTicker(
request.user.sub, request.user.sub,
params.ticker, params.ticker,
query.sync === 'observe',
); );
} }
@@ -115,9 +112,8 @@ export class StockController {
get( get(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.stocks.get(request.user.sub, uid, query.sync === 'observe'); return this.stocks.get(request.user.sub, uid);
} }
@Get(':uid/quote') @Get(':uid/quote')
@@ -125,9 +121,8 @@ export class StockController {
quote( quote(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.market.quote(request.user.sub, uid, query.sync === 'observe'); return this.market.quote(request.user.sub, uid);
} }
@Get(':uid/candles') @Get(':uid/candles')
@@ -141,7 +136,6 @@ export class StockController {
request.user.sub, request.user.sub,
uid, uid,
query.period, query.period,
query.sync === 'observe',
); );
} }
@@ -159,12 +153,10 @@ export class StockController {
company( company(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.companyInfo.execute( return this.companyInfo.execute(
request.user.sub, request.user.sub,
uid, uid,
query.sync === 'observe',
); );
} }
@@ -173,12 +165,10 @@ export class StockController {
dividends( dividends(
@Req() request: Request & { user: AccessPayload }, @Req() request: Request & { user: AccessPayload },
@Param('uid', new ParseUUIDPipe()) uid: string, @Param('uid', new ParseUUIDPipe()) uid: string,
@Query() query: InvestmentReadQueryDto,
) { ) {
return this.income.dividends( return this.income.dividends(
request.user.sub, request.user.sub,
uid, uid,
query.sync === 'observe',
); );
} }
} }
@@ -11,16 +11,15 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { import {
MAX_INSTRUMENT_QUERY_LENGTH, MAX_INSTRUMENT_QUERY_LENGTH,
MIN_INSTRUMENT_QUERY_LENGTH, MIN_INSTRUMENT_QUERY_LENGTH,
} from '../../../instrument-query'; } from '#src/modules/investments/instrument-query';
import { import {
InvestmentReadQueryDto,
InvestmentResourceMetaDto, InvestmentResourceMetaDto,
} from '../../../investment-resource.dto'; } from '#src/modules/investments/investment-resource.dto';
import { PortfolioPositionDto } from '../../../portfolio/portfolio.api'; import { PortfolioPositionDto } from '#src/modules/investments/portfolio/portfolio.api';
const MIN_TICKER_LENGTH = 1; const MIN_TICKER_LENGTH = 1;
const MAX_SCREENER_QUERY_LENGTH = 80; const MAX_SCREENER_QUERY_LENGTH = 80;
@@ -104,7 +103,7 @@ export class StockScreenerPageDto {
@ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[]; @ApiProperty({ type: StockScreenerItemDto, isArray: true, maxItems: 30 }) declare items: StockScreenerItemDto[];
} }
export class StockSearchQueryDto extends InvestmentReadQueryDto { export class StockSearchQueryDto {
@ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH }) @ApiProperty({ minLength: MIN_INSTRUMENT_QUERY_LENGTH, maxLength: MAX_INSTRUMENT_QUERY_LENGTH })
@Transform(({ value }: { value: unknown }) => @Transform(({ value }: { value: unknown }) =>
typeof value === 'string' ? value.trim() : value, typeof value === 'string' ? value.trim() : value,
@@ -126,7 +125,7 @@ export class StockTickerParamsDto {
@Matches(/^[A-Z0-9][\w.@+-]*$/i) @Matches(/^[A-Z0-9][\w.@+-]*$/i)
declare ticker: string; declare ticker: string;
} }
export class StockCandlesQueryDto extends InvestmentReadQueryDto { export class StockCandlesQueryDto {
@ApiPropertyOptional({ @ApiPropertyOptional({
enum: ['month', 'halfYear', 'year', 'all'], enum: ['month', 'halfYear', 'year', 'all'],
default: 'month', default: 'month',
@@ -1,5 +1,4 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsIn, IsOptional } from 'class-validator';
export const availabilityValues = [ export const availabilityValues = [
'available', 'available',
@@ -17,13 +16,6 @@ export const attemptStateValues = [
'failed', 'failed',
] as const; ] as const;
export class InvestmentReadQueryDto {
@ApiPropertyOptional({ enum: ['observe'] })
@IsOptional()
@IsIn(['observe'])
declare sync?: 'observe';
}
export class InvestmentAttemptProblemDto { export class InvestmentAttemptProblemDto {
@ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] }) @ApiProperty({ enum: ['membership', 'portfolio', 'operations', 'events'] })
declare section: 'events' | 'membership' | 'operations' | 'portfolio'; declare section: 'events' | 'membership' | 'operations' | 'portfolio';
@@ -1,11 +1,12 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { MILLISECONDS_PER_SECOND } from '#src/common/time';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; import { TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api'; import { TbankAccessService, TbankClient } from '#src/integrations/tbank/tbank.api';
import { import {
type OperationInterval, type OperationInterval,
operationIntervals, operationIntervals,
@@ -7,7 +7,8 @@ import {
TbankAccessService, TbankAccessService,
type TbankOperationItem, type TbankOperationItem,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar'; import { dateOnlyBounds, DateOnlyRange } from '../../investment-calendar';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { ledgerRange } from '../../investment-sync-plan'; import { ledgerRange } from '../../investment-sync-plan';
@@ -237,7 +238,6 @@ export class BrokerageOperationsService {
userId: string, userId: string,
accountId: string, accountId: string,
ranges: (openedAt: Date | undefined) => DateOnlyRange[], ranges: (openedAt: Date | undefined) => DateOnlyRange[],
observe = false,
) { ) {
const connection = await this.access.local(userId, accountId); const connection = await this.access.local(userId, accountId);
const input = { const input = {
@@ -251,21 +251,19 @@ export class BrokerageOperationsService {
periods: ranges, periods: ranges,
}; };
let saved = await this.repository.readPage(input); let saved = await this.repository.readPage(input);
if (!observe) { const targets = (saved?.connection.accounts ?? []).flatMap((account) =>
const targets = (saved?.connection.accounts ?? []).flatMap((account) => (saved?.periods ?? []).map((period) => ({
(saved?.periods ?? []).map((period) => ({ accountId: account.accountId,
accountId: account.accountId, ...period,
...period, })),
})), );
); if (targets.length) {
if (targets.length) { await this.sync.readPlan(userId, targets, {
await this.sync.readPlan(userId, targets, { movingEnd: saved?.periods?.some((period) => period.movingEnd),
movingEnd: saved?.periods?.some((period) => period.movingEnd), revision: connection.revision,
revision: connection.revision, });
});
}
saved = await this.repository.readPage(input);
} }
saved = await this.repository.readPage(input);
const now = new Date(); const now = new Date();
const hourly = await this.hourlySync.hourly(userId); const hourly = await this.hourlySync.hourly(userId);
const membership = investmentResourceMeta({ const membership = investmentResourceMeta({
@@ -1,8 +1,9 @@
import { formatDecimal } from '../../../../common/decimal'; import { formatDecimal } from '#src/common/decimal';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time'; import { MILLISECONDS_PER_SECOND } from '#src/common/time';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol'; import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol';
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api'; import { TbankMoney, TbankOperationItem } from '#src/integrations/tbank/tbank.api';
import { type OperationCategory } from '../../operation-category'; import { type OperationCategory } from '../../operation-category';
import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto'; import { BrokerageOperationDto } from '../presentation/brokerage-operation.dto';
@@ -5,7 +5,8 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { import {
dateOnlyBounds, dateOnlyBounds,
DateOnlyRange, DateOnlyRange,
@@ -2,17 +2,18 @@ import { Injectable, NotFoundException } from '@nestjs/common';
import { createHash, randomUUID } from 'node:crypto'; import { createHash, randomUUID } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util'; import { isDeepStrictEqual } from 'node:util';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol'; import { isTbankProtocolValue, TbankOperationState } from '#src/integrations/tbank/tbank-protocol';
import { claimSyncWork, lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim'; import { claimSyncWork, lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
import { import {
isJoinableRunningAttempt, isJoinableRunningAttempt,
type TbankOperationItem, type TbankOperationsByCursorResponse, type TbankOperationItem, type TbankOperationsByCursorResponse,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '../../../../prisma/to-json'; import { toJson } from '#src/prisma/to-json';
import { import {
decodeInterval, decodeInterval,
extendRunningIntent, extendRunningIntent,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service'; import { BrokerageOperationsSyncService } from './application/brokerage-operations-sync.service';
@@ -13,7 +13,8 @@ import {
Min, Min,
} from 'class-validator'; } from 'class-validator';
import { MoneyDto } from '../../../../common/money.dto'; import { MoneyDto } from '#src/common/money.dto';
import { AccountReferenceDto } from '../../account-reference.dto'; import { AccountReferenceDto } from '../../account-reference.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { operationCategories, type OperationCategory } from '../../operation-category'; import { operationCategories, type OperationCategory } from '../../operation-category';
@@ -1,4 +1,4 @@
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class PrismaService {}, PrismaService: class PrismaService {},
})); }));
@@ -3,8 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { BrokerageOperationsService } from '../application/brokerage-operations.service'; import { BrokerageOperationsService } from '../application/brokerage-operations.service';
import { import {
BrokerageOperationsQueryDto, BrokerageOperationsQueryDto,
@@ -1,8 +1,9 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { add, decimalFromString, formatDecimal } from '#src/common/decimal';
import type { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import type { InvestmentResourceMetaDto } from '../../investment-resource.dto';
import { add, decimalFromString, formatDecimal } from '../../../../common/decimal';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
ForecastRepository, ForecastRepository,
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
jest.mock('../../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { PortfolioDto } from '../presentation/portfolio.dto'; import { PortfolioDto } from '../presentation/portfolio.dto';
@@ -4,10 +4,10 @@ import {
} from '@nestjs/common'; } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { import {
InstrumentMetadataService, InstrumentMetadataService,
metadataKey, metadataKey,
@@ -17,7 +17,8 @@ import {
type TbankMoney, type TbankMoney,
type TbankPortfolio, type TbankPortfolio,
tbankSourceTtlMs, tbankSourceTtlMs,
} from '../../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { investmentResourceMeta } from '../../investment-resource.mapper'; import { investmentResourceMeta } from '../../investment-resource.mapper';
import { import {
hourlyAttempt, hourlyAttempt,
@@ -1,8 +1,9 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
jest.mock('../../../../prisma/prisma.service', () => ({ PrismaService: class {} })); jest.mock('#src/prisma/prisma.service', () => ({ PrismaService: class {} }));
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol'; import { InstrumentIdType } from '#src/integrations/tbank/tbank-protocol';
import { PositionClassificationService } from './position-classification.service'; import { PositionClassificationService } from './position-classification.service';
describe('PositionClassificationService', () => { describe('PositionClassificationService', () => {
@@ -1,12 +1,13 @@
import { Injectable, NotFoundException } from '@nestjs/common'; import { Injectable, NotFoundException } from '@nestjs/common';
import { isZero } from '../../../../common/decimal'; import { isZero } from '#src/common/decimal';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { MILLISECONDS_PER_DAY } from '../../../../common/time'; import { MILLISECONDS_PER_DAY } from '#src/common/time';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { isTbankErrorCode, TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { InstrumentIdType } from '../../../../integrations/tbank/tbank-protocol'; import { InstrumentIdType } from '#src/integrations/tbank/tbank-protocol';
import { metadataKey, TbankClient, type TbankPortfolio } from '../../../../integrations/tbank/tbank.api'; import { metadataKey, TbankClient, type TbankPortfolio } from '#src/integrations/tbank/tbank.api';
import { PositionClassificationRepository } from '../infrastructure/position-classification.repository'; import { PositionClassificationRepository } from '../infrastructure/position-classification.repository';
export type Classification = { issuer: null | { id: string; name: string }; sector: null | string }; export type Classification = { issuer: null | { id: string; name: string }; sector: null | string };
@@ -1,11 +1,11 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '#src/common/money.types';
import { import {
add, add,
DECIMAL_RADIX, DECIMAL_RADIX,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
} from '../../../../common/decimal'; } from '#src/common/decimal';
const ZERO_AMOUNT = 0n; const ZERO_AMOUNT = 0n;
const INITIAL_UNVALUED_COUNT = 0; const INITIAL_UNVALUED_COUNT = 0;
@@ -1,10 +1,10 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '#src/common/money.types';
import { import {
add, add,
decimalFromString, decimalFromString,
formatDecimal, formatDecimal,
} from '../../../../common/decimal'; } from '#src/common/decimal';
export function sumMoneyByCurrency(amounts: Money[]): Money[] { export function sumMoneyByCurrency(amounts: Money[]): Money[] {
const totals = new Map<string, ReturnType<typeof decimalFromString>>(); const totals = new Map<string, ReturnType<typeof decimalFromString>>();
@@ -1,10 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { isDeepStrictEqual } from 'node:util'; 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 '../../../../prisma/to-json'; import { toJson } from '#src/prisma/to-json';
const FIRST_BATCH_OFFSET = 0; const FIRST_BATCH_OFFSET = 0;
@@ -1,14 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import type { PortfolioDto } from '../presentation/portfolio.dto';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { lockConnectionGeneration } from '../../../../integrations/tbank/tbank-sync-claim'; import { lockConnectionGeneration } from '#src/integrations/tbank/tbank-sync-claim';
import { isJoinableRunningAttempt, tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api'; import { isJoinableRunningAttempt, tbankSourceNeedsRefresh, tbankSourceTtlMs } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { toJson } from '../../../../prisma/to-json'; import { toJson } from '#src/prisma/to-json';
import type { PortfolioDto } from '../presentation/portfolio.dto';
const EXPECTED_UPDATED_ACCOUNTS = 1; const EXPECTED_UPDATED_ACCOUNTS = 1;
@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
@Injectable() @Injectable()
export class PositionClassificationRepository { export class PositionClassificationRepository {
@@ -1,6 +1,4 @@
import type { Money } from '../../../../common/money.types'; import type { Money } from '#src/common/money.types';
import type { Classification } from '../application/position-classification.service';
import type { PortfolioDto, PortfolioPositionDto } from '../presentation/portfolio.dto';
import { import {
add, add,
@@ -9,15 +7,19 @@ import {
isZero, isZero,
multiply, multiply,
subtract, subtract,
} from '../../../../common/decimal'; } from '#src/common/decimal';
import { decimal } from '../../../../integrations/tbank/decimal'; import { decimal } from '#src/integrations/tbank/decimal';
import { import {
metadataKey, metadataKey,
type TbankInstrument, type TbankInstrument,
type TbankMoney, type TbankMoney,
TbankPortfolio, TbankPortfolio,
TbankPositions, 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'; import { portfolioStructure } from '../domain/portfolio-structure';
const types: Record<string, string> = { const types: Record<string, string> = {
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module'; import { TbankSyncRepositoryModule } from '../tbank-sync/tbank-sync-repository.module';
import { ForecastService } from './application/forecast.service'; import { ForecastService } from './application/forecast.service';
@@ -3,8 +3,9 @@ import type { Request } from 'express';
import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common'; import { Controller, Get, Param, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger'; import { ApiBearerAuth, ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '#src/modules/auth/auth.api';
import { PortfolioService } from '../application/portfolio.service'; import { PortfolioService } from '../application/portfolio.service';
import { PortfolioResourceDto } from './portfolio.dto'; import { PortfolioResourceDto } from './portfolio.dto';
@@ -1,6 +1,7 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; 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 { AccountReferenceDto } from '../../account-reference.dto';
import { InvestmentResourceMetaDto } from '../../investment-resource.dto'; import { InvestmentResourceMetaDto } from '../../investment-resource.dto';
@@ -18,7 +18,8 @@ import {
ApiTags, ApiTags,
} from '@nestjs/swagger'; } from '@nestjs/swagger';
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator'; import { ApiProblemResponses } from '#src/common/decorators/api-safe-error-response.decorator';
import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api'; import { AccessPayload, JwtAuthGuard } from '../../auth/auth.api';
import { import {
PutTbankConnectionDto, PutTbankConnectionDto,
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { TbankSyncModule } from '../tbank-sync/tbank-sync.module'; import { TbankSyncModule } from '../tbank-sync/tbank-sync.module';
import { TbankConnectionController } from './tbank-connection.controller'; import { TbankConnectionController } from './tbank-connection.controller';
@@ -1,12 +1,12 @@
import { BadRequestException, Inject, Injectable } from '@nestjs/common'; import { BadRequestException, Inject, Injectable } from '@nestjs/common';
import { HttpErrorCode } from '../../../common/error-code'; import { HttpErrorCode } from '#src/common/error-code';
import { import {
supportedTbankAccounts, supportedTbankAccounts,
TbankClient, TbankClient,
TbankTokenService, TbankTokenService,
} from '../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
type TbankTokenValidator = Pick<TbankClient, 'validateToken'>; type TbankTokenValidator = Pick<TbankClient, 'validateToken'>;
@@ -1,17 +1,18 @@
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../common/logging/structured-logger.service'; import { StructuredLogger } from '#src/common/logging/structured-logger.service';
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code'; import { TbankErrorCode } from '#src/integrations/tbank/tbank-error-code';
import { import {
TbankAccessService, TbankAccessService,
TbankMembershipService, TbankMembershipService,
type TbankSyncAttempt, type TbankSyncAttempt,
} from '../../../integrations/tbank/tbank.api'; } from '#src/integrations/tbank/tbank.api';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '#src/prisma/prisma.service';
import { BrokerageOperationsSyncService } from '../operations/operations.api'; import { BrokerageOperationsSyncService } from '../operations/operations.api';
import { PortfolioService } from '../portfolio/portfolio.api'; import { PortfolioService } from '../portfolio/portfolio.api';
import { UpcomingEventsService } from '../upcoming-events/upcoming-events.service'; import { UpcomingEventsService } from '../upcoming-events/upcoming-events.api';
import { import {
HOURLY_CAUSE_INTERRUPTED, HOURLY_CAUSE_INTERRUPTED,
type HourlyCause, type HourlyCause,
@@ -1,8 +1,9 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { ISO_DATE_LENGTH, ISO_DATE_START } from '#src/common/time';
import type { InvestmentScreenDto } from './tbank-sync.dto'; 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'; import { MIN_INSTRUMENT_QUERY_LENGTH } from '../instrument-query';
// Reject fields from other screens before reserving any source work. // Reject fields from other screens before reserving any source work.
@@ -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);
}
}
@@ -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 { export class HourlySyncStatusDto {
@ApiProperty({ @ApiProperty({
enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'], enum: ['running', 'succeeded', 'partial', 'failed', 'cancelled'],
@@ -1,6 +1,7 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TbankModule } from '../../../integrations/tbank/tbank.module'; import { TbankModule } from '#src/integrations/tbank/tbank.module';
import { AuthModule } from '../../auth/auth.module'; import { AuthModule } from '../../auth/auth.module';
import { InstrumentSourcesModule } from '../instruments/instrument-sources.module'; import { InstrumentSourcesModule } from '../instruments/instrument-sources.module';
import { OperationsModule } from '../operations/operations.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 { UpcomingEventsModule } from '../upcoming-events/upcoming-events.module';
import { TbankHourlyScheduler } from './tbank-hourly.scheduler'; import { TbankHourlyScheduler } from './tbank-hourly.scheduler';
import { TbankSyncRepositoryModule } from './tbank-sync-repository.module'; import { TbankSyncRepositoryModule } from './tbank-sync-repository.module';
import { TbankSyncController } from './tbank-sync.controller';
import { TbankSyncService } from './tbank-sync.service';
@Module({ @Module({
imports: [ imports: [
AuthModule, AuthModule,
@@ -20,8 +19,7 @@ import { TbankSyncService } from './tbank-sync.service';
UpcomingEventsModule, UpcomingEventsModule,
TbankSyncRepositoryModule, TbankSyncRepositoryModule,
], ],
controllers: [TbankSyncController], providers: [TbankHourlyScheduler],
providers: [TbankSyncService, TbankHourlyScheduler],
exports: [TbankHourlyScheduler], exports: [TbankHourlyScheduler],
}) })
export class TbankSyncModule {} export class TbankSyncModule {}
@@ -1,22 +1,12 @@
jest.mock('../../../prisma/prisma.service', () => ({ jest.mock('#src/prisma/prisma.service', () => ({
PrismaService: class {}, PrismaService: class {},
})); }));
import { import {
hourlyProgress, hourlyProgress,
screenIntent,
screenProgress, screenProgress,
} from './tbank-sync.repository'; } from './tbank-sync.repository';
describe('screen sync intent', () => {
it('restores a persisted intent and rejects a corrupted one', () => {
const intent = { now: '2026-11-02T00:00:00.000Z', scope: { screen: 'accounts' } };
expect(screenIntent(intent)).toEqual(intent);
expect(() => screenIntent({ source: 'hourly' })).toThrow('Corrupted screen sync intent');
expect(() => screenIntent(null)).toThrow('Corrupted screen sync intent');
});
});
describe('screen sync progress', () => { describe('screen sync progress', () => {
it('restores a persisted progress unchanged', () => { it('restores a persisted progress unchanged', () => {
const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true }; const progress = { attempts: { 'work-1': 'attempt-1' }, children: ['work-1'], dispatched: true };
@@ -1,24 +1,15 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import type { Prisma } from '../../../../generated/prisma/client'; import type { Prisma } from '#generated/prisma/client';
import type { TbankSyncAttempt } from '../../../integrations/tbank/tbank.api'; import 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 { InvestmentAttemptDto } from '../investment-resource.dto';
import type { InvestmentScreenDto } from './tbank-sync.dto'; import type { InvestmentScreenDto } from './tbank-sync.dto';
import { claimSyncWork, lockConnectionGeneration } from '../../../integrations/tbank/tbank-sync-claim';
import { PrismaService } from '../../../prisma/prisma.service';
import { toJson } from '../../../prisma/to-json';
export type ScreenIntent = { now: string; scope: InvestmentScreenDto };
// Json columns are written only by this repository; a shape check turns corruption into an explicit 500.
export function screenIntent(value: Prisma.JsonValue): ScreenIntent {
const intent = value as null | Partial<ScreenIntent>;
if (typeof intent?.now !== 'string' || typeof intent.scope?.screen !== 'string') {
throw new Error('Corrupted screen sync intent');
}
return intent as ScreenIntent;
}
export type ScreenProgress = { export type ScreenProgress = {
attempts?: Record<string, string>; attempts?: Record<string, string>;
children: string[]; children: string[];
@@ -188,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 = screenProgress(work.progress);
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( async progress(
attempt: TbankSyncAttempt, attempt: TbankSyncAttempt,
children: string[], children: string[],
@@ -1,102 +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 { 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<TbankSyncStatusDto> {
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 = screenIntent(saved.work.intent);
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<Awaited<ReturnType<TbankSyncRepository['read']>>>['work'],
): Promise<TbankSyncStatusDto> {
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,
};
}
}

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