refactor: simplify backend and guard boundaries #42
+10
-4
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
|
|||||||
|
|
||||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||||
модулями providers, функции и типы. Внешний production import идёт через этот
|
модулями providers, функции и типы. Внешний production import идёт через этот
|
||||||
API или файл Nest module. Чистые `integrations/tbank/decimal.ts` и
|
API или файл Nest module. Чистые `integrations/tbank/decimal.ts`,
|
||||||
|
`integrations/tbank/tbank-protocol.ts`, `integrations/tbank/tbank-error-code.ts` и
|
||||||
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
`modules/investments/operations/domain/operations-sync-plan.ts` — отдельные
|
||||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
публичные входы для protobuf conversion, protocol/error vocabulary и interval
|
||||||
не загружает DI/Prisma.
|
rules: импорт этих значений и расчётов не загружает DI/Prisma.
|
||||||
Внутри модуля используй прямые локальные импорты.
|
Внутри модуля используй прямые локальные импорты.
|
||||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||||
Новый export требует текущего потребителя; controller и persistence остаются
|
Новый export требует текущего потребителя; controller и persistence остаются
|
||||||
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
|||||||
Исключения для индексов, default values, enum/type declarations и readonly
|
Исключения для индексов, default values, enum/type declarations и readonly
|
||||||
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
||||||
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
остальные lint правила продолжают действовать. Numeric policy получает предметное имя и единицы;
|
||||||
semantic magic strings проверяются conventions/review без нового инструмента.
|
Machine codes и protocol values задаются строковыми enum у владельца.
|
||||||
|
`no-restricted-syntax` запрещает inline-коды и значения известных protocol families
|
||||||
|
в production TS; `no-unsafe-enum-comparison` проверяет сравнения enum.
|
||||||
|
Внешние открытые строки проверяются на boundary без cast и без потери unknown fallback.
|
||||||
|
Tests сохраняют независимые literal wire assertions; обычный текст и env keys
|
||||||
|
не считаются кодами. Новые словари требуют проверки владельца и расширения selectors.
|
||||||
|
|
||||||
## Logging
|
## Logging
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
|
|||||||
## Domain values
|
## Domain values
|
||||||
|
|
||||||
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
Finite business vocabulary принадлежит одному domain/module/entity owner.
|
||||||
Переиспользуй его symbols; для нового словаря без аналога — `as const` + union.
|
Переиспользуй его symbols; для machine codes и protocol values используй
|
||||||
|
строковые `enum`, для структуры с разными payload — discriminated union.
|
||||||
Сохраняй wire values; generated/external enums не переписывай вручную.
|
Сохраняй wire values; generated/external enums не переписывай вручную.
|
||||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||||
обычные labels не выноси в constants без semantic benefit.
|
обычные labels не выноси в constants без semantic benefit.
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
|
|||||||
|
|
||||||
Преобразование provider records происходит на boundary: например,
|
Преобразование provider records происходит на boundary: например,
|
||||||
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
`tbank-portfolio.mapper.ts` и `bond-money.mapper.ts`; protobuf decimal helper
|
||||||
остаётся отдельным чистым входом integration. Domain получает внутренние
|
остаётся отдельным чистым входом integration. `tbank-protocol.ts` и
|
||||||
|
`tbank-error-code.ts` также импортируются напрямую: их enums и guards
|
||||||
|
не должны загружать DI/Prisma через service exports. Domain получает внутренние
|
||||||
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
значения и не импортирует gRPC/SDK types. Не добавляй новый adapter на каждый
|
||||||
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
||||||
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
||||||
|
|||||||
@@ -107,4 +107,47 @@ export default defineConfig(
|
|||||||
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
'jest/expect-expect': ['error', { assertFunctionNames: ['expect', '**.expect'] }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: [
|
||||||
|
'src/**/*.ts',
|
||||||
|
],
|
||||||
|
ignores: [
|
||||||
|
'**/*.spec.ts',
|
||||||
|
'test/**/*.ts',
|
||||||
|
],
|
||||||
|
rules: {
|
||||||
|
'no-restricted-syntax': [
|
||||||
|
'error',
|
||||||
|
{
|
||||||
|
selector: 'Literal[value=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember > Literal)',
|
||||||
|
message: 'Use the owning enum member for machine codes and protocol values.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'TemplateElement[value.cooked=/^(?:(?:INSTRUMENT_ID_TYPE|INSTRUMENT_TYPE|OPERATION_STATE|OPERATION_TYPE|ACCOUNT_STATUS|ACCOUNT_TYPE|CANDLE_INTERVAL|COUPON_TYPE|BANK|INVALID)_[A-Z0-9]+(?:_[A-Z0-9]+)*|[A-Z][A-Z0-9_]*_(?:ERROR|UNAVAILABLE|NOT_FOUND|NOT_CONNECTED|CONFLICT|AMBIGUOUS|REGISTERED)|P[0-9]{4}|UNAUTHORIZED|FORBIDDEN|BAD_REQUEST|NOT_FOUND|UNSUPPORTED_MEDIA_TYPE)$/]:not(TSEnumMember TemplateElement)',
|
||||||
|
message: 'Use the owning enum member instead of a template machine code.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > Literal.value[value=type(string)]',
|
||||||
|
message: 'Use an enum member instead of an inline code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property:matches([key.name=/^(code|errorCode|idType|instrumentKind)$/], [key.value=/^(code|errorCode|idType|instrumentKind)$/]) > TemplateLiteral.value',
|
||||||
|
message: 'Use an enum member instead of constructing a code or protocol value.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'Property[computed=false][key.name=/^(OPERATION_TYPE|ACCOUNT_TYPE)_/]',
|
||||||
|
message: 'Use a computed enum member as the protocol mapping key.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'BinaryExpression:has(> :matches(MemberExpression[property.name=/^(code|errorCode)$/], Identifier[name=/^(code|errorCode)$/])) > Literal[value=type(string)]',
|
||||||
|
message: 'Compare codes with their enum members, not string literals.',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'PropertyDefinition[definite=true], VariableDeclarator[definite=true]',
|
||||||
|
message: 'Definite assignment assertions are forbidden. Initialize owned values; use declare only for externally populated DTO fields.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'@typescript-eslint/no-unsafe-enum-comparison': 'error',
|
||||||
|
},
|
||||||
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import {
|
|||||||
getSchemaPath,
|
getSchemaPath,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
|
||||||
export class ProblemDetailsDto {
|
export class ProblemDetailsDto {
|
||||||
@ApiProperty({ example: 401 })
|
@ApiProperty({ example: 401 })
|
||||||
declare status: number;
|
declare status: number;
|
||||||
|
|
||||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
@ApiProperty({ example: HttpErrorCode.Unauthorized })
|
||||||
declare code: string;
|
declare code: string;
|
||||||
|
|
||||||
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
// eslint-disable-next-line @darraghor/nestjs-typed/api-property-returning-array-should-set-array -- Raw OpenAPI items schema already declares type: array.
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
export enum HttpErrorCode {
|
||||||
|
BadRequest = 'BAD_REQUEST',
|
||||||
|
InternalServerError = 'INTERNAL_SERVER_ERROR',
|
||||||
|
NotFound = 'NOT_FOUND',
|
||||||
|
Unauthorized = 'UNAUTHORIZED',
|
||||||
|
UnsupportedMediaType = 'UNSUPPORTED_MEDIA_TYPE',
|
||||||
|
ValidationError = 'VALIDATION_ERROR',
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@ import {
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../error-code';
|
||||||
|
|
||||||
@Catch()
|
@Catch()
|
||||||
export class HttpExceptionFilter implements ExceptionFilter {
|
export class HttpExceptionFilter implements ExceptionFilter {
|
||||||
catch(exception: unknown, host: ArgumentsHost): void {
|
catch(exception: unknown, host: ArgumentsHost): void {
|
||||||
@@ -25,7 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
(payload as { message?: unknown })?.message,
|
(payload as { message?: unknown })?.message,
|
||||||
);
|
);
|
||||||
const code = isValidation
|
const code = isValidation
|
||||||
? 'VALIDATION_ERROR'
|
? HttpErrorCode.ValidationError
|
||||||
: this.codeFor(status, payload);
|
: this.codeFor(status, payload);
|
||||||
response.locals ??= {};
|
response.locals ??= {};
|
||||||
response.locals.publicErrorCode = code;
|
response.locals.publicErrorCode = code;
|
||||||
@@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
|||||||
return (
|
return (
|
||||||
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
// eslint-disable-next-line security/detect-object-injection -- The lookup key is a numeric HTTP status, never a property name from the request.
|
||||||
{
|
{
|
||||||
400: 'BAD_REQUEST',
|
400: HttpErrorCode.BadRequest,
|
||||||
401: 'UNAUTHORIZED',
|
401: HttpErrorCode.Unauthorized,
|
||||||
404: 'NOT_FOUND',
|
404: HttpErrorCode.NotFound,
|
||||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
415: HttpErrorCode.UnsupportedMediaType,
|
||||||
500: 'INTERNAL_SERVER_ERROR',
|
500: HttpErrorCode.InternalServerError,
|
||||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
}[status] ?? HttpErrorCode.InternalServerError
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { constants as httpStatus } from 'node:http2';
|
import { constants as httpStatus } from 'node:http2';
|
||||||
|
|
||||||
|
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||||
|
|
||||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||||
const FIRST_PAGE_OFFSET = 0;
|
const FIRST_PAGE_OFFSET = 0;
|
||||||
const EXPECTED_CURSOR_ROWS = 1;
|
const EXPECTED_CURSOR_ROWS = 1;
|
||||||
@@ -152,7 +154,7 @@ export class MoexClient {
|
|||||||
pageSize === EMPTY_PAGE_SIZE ||
|
pageSize === EMPTY_PAGE_SIZE ||
|
||||||
current.length !== Math.min(pageSize, total - start)
|
current.length !== Math.min(pageSize, total - start)
|
||||||
) {
|
) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
if (index + pageSize >= total) return rows;
|
if (index + pageSize >= total) return rows;
|
||||||
start = index + pageSize;
|
start = index + pageSize;
|
||||||
@@ -172,22 +174,22 @@ export class MoexClient {
|
|||||||
}
|
}
|
||||||
return body as IssResponse;
|
return body as IssResponse;
|
||||||
} catch {
|
} catch {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||||
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
if (!Array.isArray(block?.columns) || !Array.isArray(block?.data)) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
const columns = block.columns;
|
const columns = block.columns;
|
||||||
if (!columns.every((column) => typeof column === 'string')) {
|
if (!columns.every((column) => typeof column === 'string')) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
return block.data.map((values) => {
|
return block.data.map((values) => {
|
||||||
if (!Array.isArray(values) || values.length !== columns.length) {
|
if (!Array.isArray(values) || values.length !== columns.length) {
|
||||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
// eslint-disable-next-line security/detect-object-injection -- Keys are fixed MOEX block names; row indexes come from the validated columns array.
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
|
|||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||||
|
|
||||||
|
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||||
|
|
||||||
const EXPECTED_REGISTRY_TABLES = 1;
|
const EXPECTED_REGISTRY_TABLES = 1;
|
||||||
const RATING_COLUMN = 2;
|
const RATING_COLUMN = 2;
|
||||||
|
|
||||||
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
|
|||||||
if (this.cache.get('registry') === pending) {
|
if (this.cache.get('registry') === pending) {
|
||||||
this.cache.set('registry', pending, { ttl: 60_000 });
|
this.cache.set('registry', pending, { ttl: 60_000 });
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'NCR_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: NcrErrorCode.Unavailable });
|
||||||
});
|
});
|
||||||
this.cache.set('registry', pending);
|
this.cache.set('registry', pending);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
|
|||||||
import type { TbankInstrument } from './tbank.client';
|
import type { TbankInstrument } from './tbank.client';
|
||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (success) {
|
if (success) {
|
||||||
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: true },
|
connection: { invalidToken: true },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||||
export { metadataKey } from './instrument-metadata.repository';
|
export { metadataKey } from './instrument-metadata.repository';
|
||||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||||
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
|
|||||||
if (
|
if (
|
||||||
!identity.figi ||
|
!identity.figi ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN')
|
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||||
) {
|
) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
|
|||||||
|
|
||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||||
import { TbankTokenService } from './tbank-token.service';
|
import { TbankTokenService } from './tbank-token.service';
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ export class TbankAccessService {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: false },
|
connection: { invalidToken: false },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||||
}
|
}
|
||||||
@@ -205,7 +206,7 @@ export class TbankAccessService {
|
|||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
const claimed = await tx.tbankSyncWork.updateMany({
|
const claimed = await tx.tbankSyncWork.updateMany({
|
||||||
where: { ...attempt, state: 'running' },
|
where: { ...attempt, state: 'running' },
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
if (!claimed.count) return false;
|
if (!claimed.count) return false;
|
||||||
await tx.tbankConnection.update({
|
await tx.tbankConnection.update({
|
||||||
@@ -236,10 +237,10 @@ export class TbankAccessService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||||
}
|
}
|
||||||
if (connection.invalidToken) {
|
if (connection.invalidToken) {
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
connection.membershipAt &&
|
connection.membershipAt &&
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
|
import { TbankAccountType } from './tbank-protocol';
|
||||||
import { TbankAccount } from './tbank.client';
|
import { TbankAccount } from './tbank.client';
|
||||||
|
|
||||||
const accountTypes = {
|
const accountTypes = {
|
||||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
[TbankAccountType.TinkoffIis]: {
|
||||||
type: 'INDIVIDUAL_INVESTMENT',
|
type: 'INDIVIDUAL_INVESTMENT',
|
||||||
name: 'ИИС',
|
name: 'ИИС',
|
||||||
},
|
},
|
||||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||||
ACCOUNT_TYPE_INVEST_FUND: {
|
[TbankAccountType.InvestFund]: {
|
||||||
type: 'MONEY_MARKET_FUND',
|
type: 'MONEY_MARKET_FUND',
|
||||||
name: 'Фонд денежного рынка',
|
name: 'Фонд денежного рынка',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export enum TbankErrorCode {
|
||||||
|
InvalidToken = 'INVALID_TBANK_TOKEN',
|
||||||
|
NotConnected = 'TBANK_NOT_CONNECTED',
|
||||||
|
Unavailable = 'TBANK_UNAVAILABLE',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error messages and persisted codes cross an open string boundary.
|
||||||
|
export function isTbankErrorCode(value: unknown, expected: TbankErrorCode): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from './tbank-access.service';
|
} from './tbank-access.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -37,7 +38,7 @@ export class TbankMembershipService {
|
|||||||
.catch(() =>
|
.catch(() =>
|
||||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||||
userId,
|
userId,
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
.finally(() => this.active.delete(attemptId));
|
.finally(() => this.active.delete(attemptId));
|
||||||
@@ -62,7 +63,7 @@ export class TbankMembershipService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.access.failMembership(
|
await this.access.failMembership(
|
||||||
attempt,
|
attempt,
|
||||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { loadSync } from '@grpc/proto-loader';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
isTbankProtocolValue,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankAccountType,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankCouponType,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
TbankOperationType,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
|
it('uses the string enum values declared by the vendored protocol', () => {
|
||||||
|
const directory = join(__dirname, 'proto/contracts');
|
||||||
|
const definitions = loadSync(['users.proto', 'instruments.proto', 'operations.proto', 'marketdata.proto'].map((file) => join(directory, file)), { includeDirs: [directory], enums: String });
|
||||||
|
for (const [name, values] of Object.entries({
|
||||||
|
InstrumentIdType,
|
||||||
|
InstrumentType: TbankInstrumentType,
|
||||||
|
AccountStatus: TbankAccountStatus,
|
||||||
|
AccountType: TbankAccountType,
|
||||||
|
CandleInterval: TbankCandleInterval,
|
||||||
|
CouponType: TbankCouponType,
|
||||||
|
OperationState: TbankOperationState,
|
||||||
|
OperationType: TbankOperationType,
|
||||||
|
})) {
|
||||||
|
const definition = definitions[`tinkoff.public.invest.api.contract.v1.${name}`];
|
||||||
|
expect(definition).toHaveProperty('format', 'Protocol Buffer 3 EnumDescriptorProto');
|
||||||
|
const descriptor = definition as { type: { value: { name: string }[] } };
|
||||||
|
expect(descriptor.type.value.map((member) => member.name)).toEqual(expect.arrayContaining(Object.values(values)));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('recognizes supported wire values without accepting unknown ones', () => {
|
||||||
|
expect(isTbankProtocolValue('OPERATION_STATE_EXECUTED', TbankOperationState.Executed)).toBe(true);
|
||||||
|
expect(isTbankProtocolValue('NEW_UPSTREAM_STATE', TbankOperationState.Executed)).toBe(false);
|
||||||
|
expect(isTbankErrorCode('INVALID_TBANK_TOKEN', TbankErrorCode.InvalidToken)).toBe(true);
|
||||||
|
expect(isTbankErrorCode('NEW_UPSTREAM_ERROR', TbankErrorCode.InvalidToken)).toBe(false);
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
// String wire values for the protocol subset used by this integration.
|
||||||
|
// proto-loader decodes enums as strings; vendor contracts remain the source.
|
||||||
|
export enum InstrumentIdType {
|
||||||
|
Figi = 'INSTRUMENT_ID_TYPE_FIGI',
|
||||||
|
Uid = 'INSTRUMENT_ID_TYPE_UID',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankInstrumentType {
|
||||||
|
Bond = 'INSTRUMENT_TYPE_BOND',
|
||||||
|
Share = 'INSTRUMENT_TYPE_SHARE',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountStatus {
|
||||||
|
Open = 'ACCOUNT_STATUS_OPEN',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankAccountType {
|
||||||
|
InvestBox = 'ACCOUNT_TYPE_INVEST_BOX',
|
||||||
|
InvestFund = 'ACCOUNT_TYPE_INVEST_FUND',
|
||||||
|
Tinkoff = 'ACCOUNT_TYPE_TINKOFF',
|
||||||
|
TinkoffIis = 'ACCOUNT_TYPE_TINKOFF_IIS',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCandleInterval {
|
||||||
|
Day = 'CANDLE_INTERVAL_DAY',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationState {
|
||||||
|
Executed = 'OPERATION_STATE_EXECUTED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankCouponType {
|
||||||
|
Floating = 'COUPON_TYPE_FLOATING',
|
||||||
|
Unspecified = 'COUPON_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TbankOperationType {
|
||||||
|
BondTax = 'OPERATION_TYPE_BOND_TAX',
|
||||||
|
BrokerFee = 'OPERATION_TYPE_BROKER_FEE',
|
||||||
|
Buy = 'OPERATION_TYPE_BUY',
|
||||||
|
BuyCard = 'OPERATION_TYPE_BUY_CARD',
|
||||||
|
BuyMargin = 'OPERATION_TYPE_BUY_MARGIN',
|
||||||
|
Coupon = 'OPERATION_TYPE_COUPON',
|
||||||
|
DivExt = 'OPERATION_TYPE_DIV_EXT',
|
||||||
|
Dividend = 'OPERATION_TYPE_DIVIDEND',
|
||||||
|
DividendTax = 'OPERATION_TYPE_DIVIDEND_TAX',
|
||||||
|
DividendTransfer = 'OPERATION_TYPE_DIVIDEND_TRANSFER',
|
||||||
|
Input = 'OPERATION_TYPE_INPUT',
|
||||||
|
InputSecurities = 'OPERATION_TYPE_INPUT_SECURITIES',
|
||||||
|
MarginFee = 'OPERATION_TYPE_MARGIN_FEE',
|
||||||
|
Output = 'OPERATION_TYPE_OUTPUT',
|
||||||
|
OutputSecurities = 'OPERATION_TYPE_OUTPUT_SECURITIES',
|
||||||
|
Sell = 'OPERATION_TYPE_SELL',
|
||||||
|
SellCard = 'OPERATION_TYPE_SELL_CARD',
|
||||||
|
SellMargin = 'OPERATION_TYPE_SELL_MARGIN',
|
||||||
|
ServiceFee = 'OPERATION_TYPE_SERVICE_FEE',
|
||||||
|
SuccessFee = 'OPERATION_TYPE_SUCCESS_FEE',
|
||||||
|
Tax = 'OPERATION_TYPE_TAX',
|
||||||
|
TransBsBs = 'OPERATION_TYPE_TRANS_BS_BS',
|
||||||
|
TransIisBs = 'OPERATION_TYPE_TRANS_IIS_BS',
|
||||||
|
Unspecified = 'OPERATION_TYPE_UNSPECIFIED',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provider responses may contain values newer than our supported protocol subset.
|
||||||
|
export function isTbankProtocolValue(
|
||||||
|
value: unknown,
|
||||||
|
expected: TbankCouponType | TbankInstrumentType | TbankOperationState | TbankOperationType,
|
||||||
|
): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import { BadRequestException, Injectable } from '@nestjs/common';
|
|||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||||
import { TbankCrypto } from './tbank.crypto';
|
import { TbankCrypto } from './tbank.crypto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -26,8 +27,8 @@ export class TbankTokenService {
|
|||||||
try {
|
try {
|
||||||
return await operation();
|
return await operation();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { status } from '@grpc/grpc-js';
|
import { status } from '@grpc/grpc-js';
|
||||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { TbankOperationState } from './tbank-protocol';
|
||||||
import { TbankClient } from './tbank.client';
|
import { TbankClient } from './tbank.client';
|
||||||
|
|
||||||
describe('TbankClient operations', () => {
|
describe('TbankClient operations', () => {
|
||||||
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
|
|||||||
to: { seconds: '2' },
|
to: { seconds: '2' },
|
||||||
cursor: 'cursor',
|
cursor: 'cursor',
|
||||||
limit: 10,
|
limit: 10,
|
||||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
state: TbankOperationState.Executed,
|
||||||
};
|
};
|
||||||
|
|
||||||
it('passes a cursor operations request to OperationsService', async () => {
|
it('passes a cursor operations request to OperationsService', async () => {
|
||||||
|
|||||||
@@ -29,6 +29,14 @@ import type {
|
|||||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||||
import { Environment } from '../../config/env.validation';
|
import { Environment } from '../../config/env.validation';
|
||||||
|
import { TbankErrorCode } from './tbank-error-code';
|
||||||
|
import {
|
||||||
|
InstrumentIdType,
|
||||||
|
TbankAccountStatus,
|
||||||
|
TbankCandleInterval,
|
||||||
|
TbankInstrumentType,
|
||||||
|
TbankOperationState,
|
||||||
|
} from './tbank-protocol';
|
||||||
|
|
||||||
const INITIAL_REQUEST_COUNT = 0;
|
const INITIAL_REQUEST_COUNT = 0;
|
||||||
const MIN_RATE_DELAY_MS = 1;
|
const MIN_RATE_DELAY_MS = 1;
|
||||||
@@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = {
|
|||||||
from?: TbankTimestamp;
|
from?: TbankTimestamp;
|
||||||
instrumentId?: string;
|
instrumentId?: string;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
state: 'OPERATION_STATE_EXECUTED';
|
state: TbankOperationState.Executed;
|
||||||
to?: TbankTimestamp;
|
to?: TbankTimestamp;
|
||||||
};
|
};
|
||||||
export type TbankOperationsByCursorResponse = {
|
export type TbankOperationsByCursorResponse = {
|
||||||
@@ -256,7 +264,7 @@ export class TbankClient {
|
|||||||
token,
|
token,
|
||||||
this.users,
|
this.users,
|
||||||
'getAccounts',
|
'getAccounts',
|
||||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
{ status: TbankAccountStatus.Open },
|
||||||
).then((response) => response.accounts ?? []);
|
).then((response) => response.accounts ?? []);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,14 +287,14 @@ export class TbankClient {
|
|||||||
token: string,
|
token: string,
|
||||||
figi: string,
|
figi: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||||
}
|
}
|
||||||
|
|
||||||
getInstrumentByUid(
|
getInstrumentByUid(
|
||||||
token: string,
|
token: string,
|
||||||
instrumentUid: string,
|
instrumentUid: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
getDividends(
|
getDividends(
|
||||||
@@ -314,7 +322,7 @@ export class TbankClient {
|
|||||||
'findInstrument',
|
'findInstrument',
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
instrumentKind: TbankInstrumentType.Share,
|
||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
@@ -326,7 +334,7 @@ export class TbankClient {
|
|||||||
'findInstrument',
|
'findInstrument',
|
||||||
{
|
{
|
||||||
query,
|
query,
|
||||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
instrumentKind: TbankInstrumentType.Bond,
|
||||||
},
|
},
|
||||||
).then((response) => response.instruments ?? []);
|
).then((response) => response.instruments ?? []);
|
||||||
}
|
}
|
||||||
@@ -337,7 +345,7 @@ export class TbankClient {
|
|||||||
this.sparseInstruments,
|
this.sparseInstruments,
|
||||||
'bondBy',
|
'bondBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
@@ -367,7 +375,7 @@ export class TbankClient {
|
|||||||
this.instruments,
|
this.instruments,
|
||||||
'shareBy',
|
'shareBy',
|
||||||
{
|
{
|
||||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType.Uid,
|
||||||
id: uid,
|
id: uid,
|
||||||
},
|
},
|
||||||
).then((response) => response.instrument ?? null);
|
).then((response) => response.instrument ?? null);
|
||||||
@@ -446,7 +454,7 @@ export class TbankClient {
|
|||||||
to: {
|
to: {
|
||||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||||
},
|
},
|
||||||
interval: 'CANDLE_INTERVAL_DAY',
|
interval: TbankCandleInterval.Day,
|
||||||
limit: 2400,
|
limit: 2400,
|
||||||
},
|
},
|
||||||
).then((response) => response.candles ?? []);
|
).then((response) => response.candles ?? []);
|
||||||
@@ -454,7 +462,7 @@ export class TbankClient {
|
|||||||
|
|
||||||
private async getInstrument(
|
private async getInstrument(
|
||||||
token: string,
|
token: string,
|
||||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
idType: InstrumentIdType,
|
||||||
id: string,
|
id: string,
|
||||||
): Promise<null | TbankInstrument> {
|
): Promise<null | TbankInstrument> {
|
||||||
try {
|
try {
|
||||||
@@ -484,7 +492,7 @@ export class TbankClient {
|
|||||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||||
try {
|
try {
|
||||||
if (queueFull) {
|
if (queueFull) {
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
const response = await this.queue.run(() =>
|
const response = await this.queue.run(() =>
|
||||||
this.request<Response>(token, client, method, request),
|
this.request<Response>(token, client, method, request),
|
||||||
@@ -498,7 +506,7 @@ export class TbankClient {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const grpcCode = (error as ServiceError).code;
|
const grpcCode = (error as ServiceError).code;
|
||||||
const failureContext = queueFull
|
const failureContext = queueFull
|
||||||
? { code: 'TBANK_UNAVAILABLE' }
|
? { code: TbankErrorCode.Unavailable }
|
||||||
: { grpcCode };
|
: { grpcCode };
|
||||||
const retryable = queueFull
|
const retryable = queueFull
|
||||||
? metadataCall
|
? metadataCall
|
||||||
@@ -534,7 +542,7 @@ export class TbankClient {
|
|||||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||||
) {
|
) {
|
||||||
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
// eslint-disable-next-line preserve-caught-error -- Discard raw upstream authentication errors at the integration boundary.
|
||||||
throw new Error('INVALID_TBANK_TOKEN');
|
throw new Error(TbankErrorCode.InvalidToken);
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
grpcCode === status.NOT_FOUND &&
|
grpcCode === status.NOT_FOUND &&
|
||||||
@@ -542,10 +550,10 @@ export class TbankClient {
|
|||||||
) {
|
) {
|
||||||
throw new NotFoundException();
|
throw new NotFoundException();
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
|
|
||||||
private request<Response>(
|
private request<Response>(
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum AuthErrorCode {
|
||||||
|
EmailAlreadyRegistered = 'EMAIL_ALREADY_REGISTERED',
|
||||||
|
InvalidCredentials = 'INVALID_CREDENTIALS',
|
||||||
|
}
|
||||||
@@ -8,7 +8,10 @@ import * as bcrypt from 'bcrypt';
|
|||||||
import { createHash, randomUUID } from 'crypto';
|
import { createHash, randomUUID } from 'crypto';
|
||||||
|
|
||||||
import { Session, User } from '../../../../generated/prisma/client';
|
import { Session, User } from '../../../../generated/prisma/client';
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../prisma/prisma.service';
|
import { PrismaService } from '../../../prisma/prisma.service';
|
||||||
|
import { AuthErrorCode } from './auth-error-code';
|
||||||
import { AuthUser, RefreshPayload } from './auth.types';
|
import { AuthUser, RefreshPayload } from './auth.types';
|
||||||
|
|
||||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||||
@@ -37,7 +40,7 @@ export class AuthService {
|
|||||||
where: { email },
|
where: { email },
|
||||||
});
|
});
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
|
|
||||||
let user: User;
|
let user: User;
|
||||||
@@ -51,7 +54,7 @@ export class AuthService {
|
|||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (isUniqueConstraintError(error)) {
|
if (isUniqueConstraintError(error)) {
|
||||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -70,7 +73,7 @@ export class AuthService {
|
|||||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||||
);
|
);
|
||||||
if (!user || !valid) {
|
if (!user || !valid) {
|
||||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
||||||
}
|
}
|
||||||
return this.createSession(user);
|
return this.createSession(user);
|
||||||
}
|
}
|
||||||
@@ -84,10 +87,10 @@ export class AuthService {
|
|||||||
algorithms: ['HS256'],
|
algorithms: ['HS256'],
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
if (payload.type !== 'refresh') {
|
if (payload.type !== 'refresh') {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
const session = await this.prisma.session.findUnique({
|
const session = await this.prisma.session.findUnique({
|
||||||
where: { id: payload.sid },
|
where: { id: payload.sid },
|
||||||
@@ -102,10 +105,10 @@ export class AuthService {
|
|||||||
if (session && !session.revokedAt) {
|
if (session && !session.revokedAt) {
|
||||||
await this.prisma.session.delete({ where: { id: session.id } });
|
await this.prisma.session.delete({ where: { id: session.id } });
|
||||||
}
|
}
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
if (session.refreshTokenHash !== this.hash(token)) {
|
if (session.refreshTokenHash !== this.hash(token)) {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
return this.rotateSession(session, session.refreshTokenHash);
|
return this.rotateSession(session, session.refreshTokenHash);
|
||||||
}
|
}
|
||||||
@@ -142,7 +145,7 @@ export class AuthService {
|
|||||||
|
|
||||||
async userById(id: string): Promise<User> {
|
async userById(id: string): Promise<User> {
|
||||||
const user = await this.prisma.user.findUnique({ where: { id } });
|
const user = await this.prisma.user.findUnique({ where: { id } });
|
||||||
if (!user) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!user) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +186,7 @@ export class AuthService {
|
|||||||
data,
|
data,
|
||||||
});
|
});
|
||||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||||
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
|
|||||||
typeof error === 'object' &&
|
typeof error === 'object' &&
|
||||||
error !== null &&
|
error !== null &&
|
||||||
'code' in error &&
|
'code' in error &&
|
||||||
(error as { code?: unknown }).code === 'P2002'
|
isPrismaErrorCode(error.code, PrismaErrorCode.UniqueConstraint)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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 { Environment } from '../../../config/env.validation';
|
import { Environment } from '../../../config/env.validation';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
|
|
||||||
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
|||||||
|
|
||||||
validate(payload: AccessPayload): AccessPayload {
|
validate(payload: AccessPayload): AccessPayload {
|
||||||
if (payload.type !== 'access') {
|
if (payload.type !== 'access') {
|
||||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
}
|
}
|
||||||
return payload;
|
return payload;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
|
|
||||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import { AuthService } from '../application/auth.service';
|
import { AuthService } from '../application/auth.service';
|
||||||
import { AccessPayload } from '../application/auth.types';
|
import { AccessPayload } from '../application/auth.types';
|
||||||
import { CredentialsDto } from './auth.dto';
|
import { CredentialsDto } from './auth.dto';
|
||||||
@@ -86,7 +87,7 @@ export class AuthController {
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
const token = this.refreshToken(request);
|
const token = this.refreshToken(request);
|
||||||
if (!token) throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
if (!token) throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||||
return this.respond(response, await this.auth.refresh(token));
|
return this.respond(response, await this.auth.refresh(token));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
||||||
|
|||||||
@@ -22,24 +22,23 @@ import {
|
|||||||
const NEXT_MONTH_OFFSET = 1;
|
const NEXT_MONTH_OFFSET = 1;
|
||||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||||
|
|
||||||
export const BankErrorCode = {
|
export enum BankErrorCode {
|
||||||
InvalidInput: 'BANK_INVALID_INPUT',
|
CardArchived = 'BANK_CARD_ARCHIVED',
|
||||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
|
||||||
NotFound: 'BANK_NOT_FOUND',
|
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
|
||||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
|
||||||
InvalidDate: 'BANK_INVALID_DATE',
|
IntentConflict = 'BANK_INTENT_CONFLICT',
|
||||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
InvalidDate = 'BANK_INVALID_DATE',
|
||||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
InvalidInput = 'BANK_INVALID_INPUT',
|
||||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
InvalidMoney = 'BANK_INVALID_MONEY',
|
||||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
InvalidRefund = 'BANK_INVALID_REFUND',
|
||||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
NotFound = 'BANK_NOT_FOUND',
|
||||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
|
||||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
OperationDeleted = 'BANK_OPERATION_DELETED',
|
||||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
RefundLimit = 'BANK_REFUND_LIMIT',
|
||||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
VersionConflict = 'BANK_VERSION_CONFLICT',
|
||||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
WriteConflict = 'BANK_WRITE_CONFLICT',
|
||||||
} as const;
|
}
|
||||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
|
||||||
|
|
||||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||||
throw new BadRequestException({ code });
|
throw new BadRequestException({ code });
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { Prisma } from '../../../../../generated/prisma/client';
|
import { Prisma } from '../../../../../generated/prisma/client';
|
||||||
|
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
BankErrorCode,
|
BankErrorCode,
|
||||||
@@ -44,15 +45,15 @@ export class BankCardsRepository {
|
|||||||
? error.code
|
? error.code
|
||||||
: '';
|
: '';
|
||||||
const busy =
|
const busy =
|
||||||
code === 'P2034' ||
|
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
|
||||||
code === 'P2028' ||
|
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||||
(error instanceof Error &&
|
(error instanceof Error &&
|
||||||
/SQLITE_BUSY|database is locked/.test(error.message));
|
/SQLITE_BUSY|database is locked/.test(error.message));
|
||||||
if ((busy || code === 'P2002') && attempt < MAX_WRITE_RETRY_COUNT) {
|
if ((busy || isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) && attempt < MAX_WRITE_RETRY_COUNT) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
formatDecimal,
|
formatDecimal,
|
||||||
subtract,
|
subtract,
|
||||||
} from '../../../common/decimal';
|
} from '../../../common/decimal';
|
||||||
|
import { isTbankProtocolValue, TbankOperationType } from '../../../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 {
|
||||||
@@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
|
|||||||
return entries.filter(
|
return entries.filter(
|
||||||
(entry) =>
|
(entry) =>
|
||||||
Boolean(entry.payment) &&
|
Boolean(entry.payment) &&
|
||||||
['OPERATION_TYPE_COUPON', 'OPERATION_TYPE_DIVIDEND'].includes(entry.type),
|
[TbankOperationType.Coupon, TbankOperationType.Dividend].some((type) => isTbankProtocolValue(entry.type, type)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,7 +185,7 @@ function totals(entries: BrokerageOperationDto[]): IncomeCurrencySummaryDto[] {
|
|||||||
if (!entry.payment) continue;
|
if (!entry.payment) continue;
|
||||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||||
const value = decimalFromString(entry.payment.value);
|
const value = decimalFromString(entry.payment.value);
|
||||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||||
total.coupon = add(total.coupon, value);
|
total.coupon = add(total.coupon, value);
|
||||||
} else total.dividend = add(total.dividend, value);
|
} else total.dividend = add(total.dividend, value);
|
||||||
values.set(entry.payment.currency, total);
|
values.set(entry.payment.currency, total);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
utcDate,
|
utcDate,
|
||||||
} from '../investment-calendar';
|
} from '../investment-calendar';
|
||||||
|
|
||||||
|
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||||
|
|
||||||
const MONDAY_WEEKDAY_INDEX = 1;
|
const MONDAY_WEEKDAY_INDEX = 1;
|
||||||
const INCLUSIVE_DAY_OFFSET = 1;
|
const INCLUSIVE_DAY_OFFSET = 1;
|
||||||
const NEXT_DAY_OFFSET = 1;
|
const NEXT_DAY_OFFSET = 1;
|
||||||
@@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function invalidPeriod(): BadRequestException {
|
function invalidPeriod(): BadRequestException {
|
||||||
return new BadRequestException({ code: 'INVALID_INCOME_PERIOD' });
|
return new BadRequestException({ code: IncomeErrorCode.InvalidPeriod });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatDecimal } from '../../../../common/decimal';
|
|||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
import { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -23,6 +24,7 @@ import {
|
|||||||
candleWindows,
|
candleWindows,
|
||||||
utcDay,
|
utcDay,
|
||||||
} from '../domain/candle-coverage';
|
} from '../domain/candle-coverage';
|
||||||
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||||
import { timestamp } from './instrument-market';
|
import { timestamp } from './instrument-market';
|
||||||
@@ -71,8 +73,8 @@ function rangeFor(
|
|||||||
throw new ServiceUnavailableException({
|
throw new ServiceUnavailableException({
|
||||||
code:
|
code:
|
||||||
input.instrumentType === 'share'
|
input.instrumentType === 'share'
|
||||||
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
? InstrumentErrorCode.StockHistoryStartUnavailable
|
||||||
: 'BOND_HISTORY_START_UNAVAILABLE',
|
: InstrumentErrorCode.BondHistoryStartUnavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
from.setTime(utcDay(new Date(history)).valueOf());
|
from.setTime(utcDay(new Date(history)).valueOf());
|
||||||
@@ -448,7 +450,7 @@ export class CandleService {
|
|||||||
const producer = this.queue.shift();
|
const producer = this.queue.shift();
|
||||||
if (!producer) break;
|
if (!producer) break;
|
||||||
let success = false;
|
let success = false;
|
||||||
let code = 'TBANK_UNAVAILABLE';
|
let code: string = TbankErrorCode.Unavailable;
|
||||||
try {
|
try {
|
||||||
const result = await this.load(producer);
|
const result = await this.load(producer);
|
||||||
if (result === null) {
|
if (result === null) {
|
||||||
@@ -457,7 +459,7 @@ export class CandleService {
|
|||||||
}
|
}
|
||||||
success = result;
|
success = result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
}
|
}
|
||||||
if (error instanceof ServiceUnavailableException) {
|
if (error instanceof ServiceUnavailableException) {
|
||||||
@@ -523,7 +525,7 @@ export class CandleService {
|
|||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
this.logger.event('error', 'candles.persistence-failed', {
|
this.logger.event('error', 'candles.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
type TbankSyncAttempt,
|
type TbankSyncAttempt,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||||
|
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||||
import {
|
import {
|
||||||
CatalogDiscoveryRepository,
|
CatalogDiscoveryRepository,
|
||||||
catalogQuery,
|
catalogQuery,
|
||||||
@@ -209,8 +211,8 @@ export class CatalogDiscoveryService {
|
|||||||
throw new ConflictException({
|
throw new ConflictException({
|
||||||
code:
|
code:
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
? 'STOCK_TICKER_AMBIGUOUS'
|
? InstrumentErrorCode.StockTickerAmbiguous
|
||||||
: 'BOND_TICKER_AMBIGUOUS',
|
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const result = lookup.data
|
const result = lookup.data
|
||||||
@@ -357,11 +359,11 @@ export class CatalogDiscoveryService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -395,7 +397,7 @@ export class CatalogDiscoveryService {
|
|||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
this.queue.length = 0;
|
this.queue.length = 0;
|
||||||
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
@@ -520,11 +521,11 @@ export class CatalogEnrichmentService {
|
|||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed = true;
|
failed = true;
|
||||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||||
await this.access.rejectMembershipToken(producer.attempt);
|
await this.access.rejectMembershipToken(producer.attempt);
|
||||||
} else {
|
} else {
|
||||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -572,7 +573,7 @@ export class CatalogEnrichmentService {
|
|||||||
this.producers.clear();
|
this.producers.clear();
|
||||||
this.queue.length = 0;
|
this.queue.length = 0;
|
||||||
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||||
code: 'TBANK_UNAVAILABLE',
|
code: TbankErrorCode.Unavailable,
|
||||||
});
|
});
|
||||||
} finally {
|
} finally {
|
||||||
this.running = false;
|
this.running = false;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
|
|
||||||
export const catalogSearchLimit = 30;
|
export const catalogSearchLimit = 30;
|
||||||
|
|
||||||
export function selectCatalogInstruments<T extends TbankShare>(
|
export function selectCatalogInstruments<T extends TbankShare>(
|
||||||
@@ -10,8 +12,8 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
|||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const kind =
|
const kind =
|
||||||
instrumentType === 'share'
|
instrumentType === 'share'
|
||||||
? 'INSTRUMENT_TYPE_SHARE'
|
? TbankInstrumentType.Share
|
||||||
: 'INSTRUMENT_TYPE_BOND';
|
: TbankInstrumentType.Bond;
|
||||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||||
return found
|
return found
|
||||||
.filter(
|
.filter(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
multiply,
|
multiply,
|
||||||
} from '../../../../../common/decimal';
|
} from '../../../../../common/decimal';
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||||
|
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||||
import {
|
import {
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
type TbankBond,
|
type TbankBond,
|
||||||
@@ -241,10 +242,10 @@ export function bondMetrics(
|
|||||||
)
|
)
|
||||||
: null;
|
: null;
|
||||||
const couponType =
|
const couponType =
|
||||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
|
next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
|
||||||
? next.couponType
|
? next.couponType
|
||||||
: instrument.floatingCouponFlag === true
|
: instrument.floatingCouponFlag === true
|
||||||
? 'COUPON_TYPE_FLOATING'
|
? TbankCouponType.Floating
|
||||||
: null;
|
: null;
|
||||||
return {
|
return {
|
||||||
creditRating,
|
creditRating,
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export enum InstrumentErrorCode {
|
||||||
|
BondHistoryStartUnavailable = 'BOND_HISTORY_START_UNAVAILABLE',
|
||||||
|
BondTickerAmbiguous = 'BOND_TICKER_AMBIGUOUS',
|
||||||
|
StockHistoryStartUnavailable = 'STOCK_HISTORY_START_UNAVAILABLE',
|
||||||
|
StockTickerAmbiguous = 'STOCK_TICKER_AMBIGUOUS',
|
||||||
|
}
|
||||||
@@ -3,12 +3,9 @@ import { 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 type {
|
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||||
TbankBond,
|
|
||||||
TbankShare,
|
|
||||||
TbankSyncAttempt,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
|
|
||||||
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||||
@@ -462,7 +459,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: true },
|
connection: { invalidToken: true },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||||
});
|
});
|
||||||
await tx.tbankSyncWork.updateMany({
|
await tx.tbankSyncWork.updateMany({
|
||||||
where: {
|
where: {
|
||||||
@@ -472,7 +469,7 @@ export class CatalogDiscoveryRepository {
|
|||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
state: success ? 'complete' : 'failed',
|
state: success ? 'complete' : 'failed',
|
||||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto';
|
|||||||
|
|
||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import {
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
TbankAccessService,
|
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
TbankClient,
|
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { accountHistory } from '../../investment-sync-plan';
|
import { accountHistory } from '../../investment-sync-plan';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
@@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService {
|
|||||||
this.logger.event(
|
this.logger.event(
|
||||||
'error',
|
'error',
|
||||||
'tbank.operations.sync.persistence_failed',
|
'tbank.operations.sync.persistence_failed',
|
||||||
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
|
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
const next = pending ? await this.repository.pending(attempt) : null;
|
const next = pending ? await this.repository.pending(attempt) : null;
|
||||||
@@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService {
|
|||||||
: {}),
|
: {}),
|
||||||
cursor,
|
cursor,
|
||||||
limit: operationsPageSize,
|
limit: operationsPageSize,
|
||||||
state: 'OPERATION_STATE_EXECUTED',
|
state: TbankOperationState.Executed,
|
||||||
});
|
});
|
||||||
if (
|
if (
|
||||||
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||||
@@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await this.repository.fail(
|
await this.repository.fail(
|
||||||
attempt,
|
attempt,
|
||||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||||
);
|
);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
ledgerRange,
|
ledgerRange,
|
||||||
planInvestmentScreen,
|
planInvestmentScreen,
|
||||||
} from '../../investment-sync-plan';
|
} from '../../investment-sync-plan';
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
operationIntervalsOverlap,
|
operationIntervalsOverlap,
|
||||||
operationsSyncPriority,
|
operationsSyncPriority,
|
||||||
@@ -442,7 +443,7 @@ export class BrokerageOperationsService {
|
|||||||
}
|
}
|
||||||
return cursor as LocalOperationsCursor;
|
return cursor as LocalOperationsCursor;
|
||||||
} catch {
|
} catch {
|
||||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export enum OperationErrorCode {
|
||||||
|
InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR',
|
||||||
|
}
|
||||||
@@ -1,10 +1,8 @@
|
|||||||
import { formatDecimal } from '../../../../common/decimal';
|
import { formatDecimal } from '../../../../common/decimal';
|
||||||
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
import {
|
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
TbankMoney,
|
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||||
TbankOperationItem,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import {
|
import {
|
||||||
BrokerageOperationDto,
|
BrokerageOperationDto,
|
||||||
OperationCategory,
|
OperationCategory,
|
||||||
@@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0;
|
|||||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||||
|
|
||||||
const types: Record<string, OperationCategory> = {
|
const types: Record<string, OperationCategory> = {
|
||||||
OPERATION_TYPE_BUY: 'trade',
|
[TbankOperationType.Buy]: 'trade',
|
||||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
[TbankOperationType.BuyCard]: 'trade',
|
||||||
OPERATION_TYPE_SELL: 'trade',
|
[TbankOperationType.Sell]: 'trade',
|
||||||
OPERATION_TYPE_SELL_CARD: 'trade',
|
[TbankOperationType.SellCard]: 'trade',
|
||||||
OPERATION_TYPE_BUY_MARGIN: 'trade',
|
[TbankOperationType.BuyMargin]: 'trade',
|
||||||
OPERATION_TYPE_SELL_MARGIN: 'trade',
|
[TbankOperationType.SellMargin]: 'trade',
|
||||||
OPERATION_TYPE_COUPON: 'income',
|
[TbankOperationType.Coupon]: 'income',
|
||||||
OPERATION_TYPE_DIVIDEND: 'income',
|
[TbankOperationType.Dividend]: 'income',
|
||||||
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
|
[TbankOperationType.DividendTransfer]: 'income',
|
||||||
OPERATION_TYPE_DIV_EXT: 'income',
|
[TbankOperationType.DivExt]: 'income',
|
||||||
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
|
[TbankOperationType.TransIisBs]: 'transfer',
|
||||||
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
|
[TbankOperationType.TransBsBs]: 'transfer',
|
||||||
OPERATION_TYPE_BOND_TAX: 'tax',
|
[TbankOperationType.BondTax]: 'tax',
|
||||||
OPERATION_TYPE_TAX: 'tax',
|
[TbankOperationType.Tax]: 'tax',
|
||||||
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
|
[TbankOperationType.DividendTax]: 'tax',
|
||||||
OPERATION_TYPE_SERVICE_FEE: 'fee',
|
[TbankOperationType.ServiceFee]: 'fee',
|
||||||
OPERATION_TYPE_MARGIN_FEE: 'fee',
|
[TbankOperationType.MarginFee]: 'fee',
|
||||||
OPERATION_TYPE_BROKER_FEE: 'fee',
|
[TbankOperationType.BrokerFee]: 'fee',
|
||||||
OPERATION_TYPE_SUCCESS_FEE: 'fee',
|
[TbankOperationType.SuccessFee]: 'fee',
|
||||||
OPERATION_TYPE_INPUT: 'transfer',
|
[TbankOperationType.Input]: 'transfer',
|
||||||
OPERATION_TYPE_OUTPUT: 'transfer',
|
[TbankOperationType.Output]: 'transfer',
|
||||||
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
|
[TbankOperationType.InputSecurities]: 'transfer',
|
||||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
|
[TbankOperationType.OutputSecurities]: 'transfer',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function operationCategory(type: string | undefined): OperationCategory {
|
export function operationCategory(type: string | undefined): OperationCategory {
|
||||||
@@ -51,7 +49,7 @@ export function mapBrokerageOperation(
|
|||||||
return {
|
return {
|
||||||
id: operation.id ?? operation.cursor ?? '',
|
id: operation.id ?? operation.cursor ?? '',
|
||||||
occurredAt: occurredAt.toISOString(),
|
occurredAt: occurredAt.toISOString(),
|
||||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
type: operation.type ?? TbankOperationType.Unspecified,
|
||||||
category: operationCategory(operation.type),
|
category: operationCategory(operation.type),
|
||||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||||
figi: text(operation.figi),
|
figi: text(operation.figi),
|
||||||
|
|||||||
+6
-3
@@ -12,12 +12,15 @@ import type {
|
|||||||
TbankOperationsByCursorResponse,
|
TbankOperationsByCursorResponse,
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
} from '../../../../integrations/tbank/tbank.api';
|
||||||
|
|
||||||
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
|
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
import {
|
import {
|
||||||
dateOnlyBounds,
|
dateOnlyBounds,
|
||||||
DateOnlyRange,
|
DateOnlyRange,
|
||||||
moscowDate,
|
moscowDate,
|
||||||
} from '../../investment-calendar';
|
} from '../../investment-calendar';
|
||||||
|
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||||
import {
|
import {
|
||||||
type OperationInterval,
|
type OperationInterval,
|
||||||
operationIntervals,
|
operationIntervals,
|
||||||
@@ -137,7 +140,7 @@ export class BrokerageOperationsRepository {
|
|||||||
)
|
)
|
||||||
.digest('base64url');
|
.digest('base64url');
|
||||||
if (input.cursor && input.cursor.binding !== binding) {
|
if (input.cursor && input.cursor.binding !== binding) {
|
||||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||||
}
|
}
|
||||||
const scope = {
|
const scope = {
|
||||||
userId: input.userId,
|
userId: input.userId,
|
||||||
@@ -615,7 +618,7 @@ export class BrokerageOperationsRepository {
|
|||||||
where: { id: attempt.id },
|
where: { id: attempt.id },
|
||||||
data: {
|
data: {
|
||||||
state: 'failed',
|
state: 'failed',
|
||||||
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (invalidToken) {
|
if (invalidToken) {
|
||||||
@@ -784,7 +787,7 @@ export class BrokerageOperationsRepository {
|
|||||||
}
|
}
|
||||||
const rows = staged.filter((row) => {
|
const rows = staged.filter((row) => {
|
||||||
const state = (row.payload as TbankOperationItem).state;
|
const state = (row.payload as TbankOperationItem).state;
|
||||||
return !state || state === 'OPERATION_STATE_EXECUTED';
|
return !state || isTbankProtocolValue(state, TbankOperationState.Executed);
|
||||||
});
|
});
|
||||||
const scope = {
|
const scope = {
|
||||||
userId: attempt.userId,
|
userId: attempt.userId,
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { RequestContextService } from '../../../../common/logging/request-contex
|
|||||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||||
import { MoneyDto } from '../../../../common/money.dto';
|
import { MoneyDto } from '../../../../common/money.dto';
|
||||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||||
|
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
type MetadataIdentity,
|
type MetadataIdentity,
|
||||||
@@ -387,7 +388,7 @@ export class PortfolioService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (
|
if (
|
||||||
error instanceof Error &&
|
error instanceof Error &&
|
||||||
error.message === 'INVALID_TBANK_TOKEN'
|
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)
|
||||||
) {
|
) {
|
||||||
await this.access.rejectMembershipToken(attempt);
|
await this.access.rejectMembershipToken(attempt);
|
||||||
} else await this.repository.fail(attempt);
|
} else await this.repository.fail(attempt);
|
||||||
@@ -412,7 +413,7 @@ export class PortfolioService {
|
|||||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||||
const result = await this.read(userId, accountId);
|
const result = await this.read(userId, accountId);
|
||||||
if (!result.data) {
|
if (!result.data) {
|
||||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||||
}
|
}
|
||||||
return result.data;
|
return result.data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,8 @@ 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 type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||||
|
|
||||||
import {
|
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||||
tbankSourceNeedsRefresh,
|
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
||||||
tbankSourceTtlMs,
|
|
||||||
} from '../../../../integrations/tbank/tbank.api';
|
|
||||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||||
|
|
||||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||||
@@ -152,7 +150,7 @@ export class PortfolioRepository {
|
|||||||
state: 'running',
|
state: 'running',
|
||||||
connection: { invalidToken: false },
|
connection: { invalidToken: false },
|
||||||
},
|
},
|
||||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||||
});
|
});
|
||||||
return result.count === EXPECTED_UPDATED_ACCOUNTS;
|
return result.count === EXPECTED_UPDATED_ACCOUNTS;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { HttpErrorCode } from '../../../common/error-code';
|
||||||
import {
|
import {
|
||||||
supportedTbankAccounts,
|
supportedTbankAccounts,
|
||||||
TbankClient,
|
TbankClient,
|
||||||
@@ -29,7 +30,7 @@ export class TbankConnectionService {
|
|||||||
|
|
||||||
async put(userId: string, value: string) {
|
async put(userId: string, value: string) {
|
||||||
const token = value.trim();
|
const token = value.trim();
|
||||||
if (!token) throw new BadRequestException({ code: 'VALIDATION_ERROR' });
|
if (!token) throw new BadRequestException({ code: HttpErrorCode.ValidationError });
|
||||||
const accounts = await this.tokens.translateInvalidToken(() =>
|
const accounts = await this.tokens.translateInvalidToken(() =>
|
||||||
this.tbank.validateToken(token),
|
this.tbank.validateToken(token),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
|
|||||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||||
|
|
||||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
||||||
|
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
||||||
import {
|
import {
|
||||||
InstrumentMetadataService,
|
InstrumentMetadataService,
|
||||||
TbankAccessService,
|
TbankAccessService,
|
||||||
@@ -325,7 +326,7 @@ export class TbankSyncService {
|
|||||||
await this.repository.progress(attempt, children, true);
|
await this.repository.progress(attempt, children, true);
|
||||||
} catch {
|
} catch {
|
||||||
await this.repository
|
await this.repository
|
||||||
.progress(attempt, children, true, 'TBANK_UNAVAILABLE')
|
.progress(attempt, children, true, TbankErrorCode.Unavailable)
|
||||||
.catch(() => undefined);
|
.catch(() => undefined);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import {
|
|||||||
UpcomingEventsQueryDto,
|
UpcomingEventsQueryDto,
|
||||||
} from './upcoming-events.dto';
|
} from './upcoming-events.dto';
|
||||||
|
|
||||||
|
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
|
||||||
|
|
||||||
const FIRST_BATCH_OFFSET = 0;
|
const FIRST_BATCH_OFFSET = 0;
|
||||||
|
|
||||||
const DEFAULT_EVENT_WINDOW_DAYS = 30;
|
const DEFAULT_EVENT_WINDOW_DAYS = 30;
|
||||||
@@ -267,7 +269,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
|||||||
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
|
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
|
||||||
const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||||
if (from > to) {
|
if (from > to) {
|
||||||
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
|
throw new BadRequestException({ code: EventsErrorCode.InvalidPeriod });
|
||||||
}
|
}
|
||||||
return { from, to };
|
return { from, to };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
export enum PrismaErrorCode {
|
||||||
|
TransactionFailure = 'P2028',
|
||||||
|
UniqueConstraint = 'P2002',
|
||||||
|
WriteConflict = 'P2034',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPrismaErrorCode(value: unknown, expected: PrismaErrorCode): boolean {
|
||||||
|
return value === expected;
|
||||||
|
}
|
||||||
@@ -61,3 +61,27 @@ test('numeric literals require names, including previously ignored numbers and b
|
|||||||
assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal);
|
assert.ok(!named.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-magic-numbers'), literal);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Single-use codes must be caught too; fixture literals remain independent wire assertions.
|
||||||
|
test('machine codes require enum members without banning ordinary text or Prisma selects', async () => {
|
||||||
|
const eslint = new ESLint();
|
||||||
|
const filePath = 'src/main.ts';
|
||||||
|
for (const code of ['export function check(code: string) { return code === "one-off-code"; }', 'export const result = `MOEX_UNAVAILABLE`;', 'export const result = { code: "one-off-code" };', 'export const result = { "errorCode": "one-off-code" };', 'export const result = { code: `dynamic-${Date.now()}` };', 'export const result = "MOEX_UNAVAILABLE";', 'export const result = ["INSTRUMENT_ID_TYPE_FIGI"];', 'export const result = { OPERATION_TYPE_COUPON: "income" };']) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code);
|
||||||
|
}
|
||||||
|
for (const code of [
|
||||||
|
'enum Code { Unavailable = "MOEX_UNAVAILABLE" } export const result = { code: Code.Unavailable };',
|
||||||
|
'export const select = { code: true, errorCode: true };',
|
||||||
|
'export const label = "Please try again"; export const key = "JWT_SECRET";',
|
||||||
|
]) {
|
||||||
|
const [result] = await eslint.lintText(code, { filePath });
|
||||||
|
assert.equal(result.fatalErrorCount, 0, code);
|
||||||
|
assert.ok(!result.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'), code);
|
||||||
|
}
|
||||||
|
const [fixture] = await eslint.lintText('export const result = { code: "MOEX_UNAVAILABLE" };', { filePath: 'src/integrations/moex/moex.client.spec.ts' });
|
||||||
|
assert.ok(!fixture.messages.some(({ ruleId }) => ruleId === 'no-restricted-syntax'));
|
||||||
|
const [comparison] = await eslint.lintText('enum Code { Ready = "ready" } export function check(code: Code) { return code === "ready"; }', { filePath });
|
||||||
|
assert.ok(comparison.messages.some(({ ruleId }) => ruleId === '@typescript-eslint/no-unsafe-enum-comparison'));
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user