chore: strengthen backend eslint and value policies #40
+10
-4
@@ -160,10 +160,11 @@ HTTP/e2e naming и расположение — в [Testing](conventions/testing
|
||||
|
||||
`*.module.ts` задаёт DI boundary; `*.api.ts` перечисляет используемые другими
|
||||
модулями 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` — отдельные
|
||||
публичные входы для protobuf conversion и interval rules: импорт расчётов
|
||||
не загружает DI/Prisma.
|
||||
публичные входы для protobuf conversion, protocol/error vocabulary и interval
|
||||
rules: импорт этих значений и расчётов не загружает DI/Prisma.
|
||||
Внутри модуля используй прямые локальные импорты.
|
||||
Tests могут импортировать внутренний owner для проверки поведения и mocks.
|
||||
Новый export требует текущего потребителя; controller и persistence остаются
|
||||
@@ -251,7 +252,12 @@ OpenAPI enum metadata и runtime validation должны читать тот ж
|
||||
Исключения для индексов, default values, enum/type declarations и readonly
|
||||
properties заданы в ESLint config. Test fixtures/expected values остаются inline,
|
||||
остальные 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
|
||||
|
||||
|
||||
@@ -29,7 +29,8 @@ owner и 1–3 примера. Код показывает механизм; с
|
||||
## Domain values
|
||||
|
||||
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 не переписывай вручную.
|
||||
Numeric policy называй по смыслу и единицам. Индексы, `Math.max(value, 0)` и
|
||||
обычные labels не выноси в constants без semantic benefit.
|
||||
|
||||
@@ -22,7 +22,9 @@ RPC, credentials/access и resilience. Эти владельцы не объед
|
||||
|
||||
Преобразование provider records происходит на boundary: например,
|
||||
`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 на каждый
|
||||
client или mapper для простого копирования полей. Даты/units/nano разбираются
|
||||
на границе клиента/его mapping, а не становятся моделью чистых расчётов.
|
||||
|
||||
@@ -107,4 +107,47 @@ export default defineConfig(
|
||||
'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,
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { HttpErrorCode } from '../error-code';
|
||||
|
||||
export class ProblemDetailsDto {
|
||||
@ApiProperty({ example: 401 })
|
||||
declare status: number;
|
||||
|
||||
@ApiProperty({ example: 'UNAUTHORIZED' })
|
||||
@ApiProperty({ example: HttpErrorCode.Unauthorized })
|
||||
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.
|
||||
|
||||
@@ -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,
|
||||
} from '@nestjs/common';
|
||||
|
||||
import { HttpErrorCode } from '../error-code';
|
||||
|
||||
@Catch()
|
||||
export class HttpExceptionFilter implements ExceptionFilter {
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
@@ -25,7 +27,7 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
(payload as { message?: unknown })?.message,
|
||||
);
|
||||
const code = isValidation
|
||||
? 'VALIDATION_ERROR'
|
||||
? HttpErrorCode.ValidationError
|
||||
: this.codeFor(status, payload);
|
||||
response.locals ??= {};
|
||||
response.locals.publicErrorCode = code;
|
||||
@@ -53,12 +55,12 @@ export class HttpExceptionFilter implements ExceptionFilter {
|
||||
return (
|
||||
// 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',
|
||||
401: 'UNAUTHORIZED',
|
||||
404: 'NOT_FOUND',
|
||||
415: 'UNSUPPORTED_MEDIA_TYPE',
|
||||
500: 'INTERNAL_SERVER_ERROR',
|
||||
}[status] ?? 'INTERNAL_SERVER_ERROR'
|
||||
400: HttpErrorCode.BadRequest,
|
||||
401: HttpErrorCode.Unauthorized,
|
||||
404: HttpErrorCode.NotFound,
|
||||
415: HttpErrorCode.UnsupportedMediaType,
|
||||
500: HttpErrorCode.InternalServerError,
|
||||
}[status] ?? HttpErrorCode.InternalServerError
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Injectable, ServiceUnavailableException } from '@nestjs/common';
|
||||
import { constants as httpStatus } from 'node:http2';
|
||||
|
||||
enum MoexErrorCode { Unavailable = 'MOEX_UNAVAILABLE' }
|
||||
|
||||
const MIN_PRICE_PERCENT_EXCLUSIVE = 0;
|
||||
const FIRST_PAGE_OFFSET = 0;
|
||||
const EXPECTED_CURSOR_ROWS = 1;
|
||||
@@ -152,7 +154,7 @@ export class MoexClient {
|
||||
pageSize === EMPTY_PAGE_SIZE ||
|
||||
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;
|
||||
start = index + pageSize;
|
||||
@@ -172,22 +174,22 @@ export class MoexClient {
|
||||
}
|
||||
return body as IssResponse;
|
||||
} catch {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function parseRows(block: IssBlock | undefined): Record<string, unknown>[] {
|
||||
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;
|
||||
if (!columns.every((column) => typeof column === 'string')) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
return block.data.map((values) => {
|
||||
if (!Array.isArray(values) || values.length !== columns.length) {
|
||||
throw new ServiceUnavailableException({ code: 'MOEX_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: MoexErrorCode.Unavailable });
|
||||
}
|
||||
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.
|
||||
|
||||
@@ -4,6 +4,8 @@ import { LRUCache } from 'lru-cache';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../common/time';
|
||||
|
||||
enum NcrErrorCode { Unavailable = 'NCR_UNAVAILABLE' }
|
||||
|
||||
const EXPECTED_REGISTRY_TABLES = 1;
|
||||
const RATING_COLUMN = 2;
|
||||
|
||||
@@ -41,7 +43,7 @@ export class NcrRatingsClient {
|
||||
if (this.cache.get('registry') === pending) {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { TbankSyncAttempt } from './tbank-access.service';
|
||||
import type { TbankInstrument } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceTtlMs } from './tbank-source.policy';
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
@@ -276,7 +277,7 @@ export class InstrumentMetadataRepository {
|
||||
},
|
||||
data: {
|
||||
state: success ? 'complete' : 'failed',
|
||||
errorCode: success ? null : 'TBANK_UNAVAILABLE',
|
||||
errorCode: success ? null : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
if (success) {
|
||||
@@ -286,7 +287,7 @@ export class InstrumentMetadataRepository {
|
||||
state: 'running',
|
||||
connection: { invalidToken: true },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
TbankAccessService,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient, type TbankInstrument } from './tbank.client';
|
||||
export { metadataKey } from './instrument-metadata.repository';
|
||||
export type { MetadataIdentity } from './instrument-metadata.repository';
|
||||
@@ -131,7 +132,7 @@ export class InstrumentMetadataService {
|
||||
if (
|
||||
!identity.figi ||
|
||||
(error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN')
|
||||
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken))
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
@@ -191,11 +192,11 @@ export class InstrumentMetadataService {
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
} else {
|
||||
this.logger.event('warn', 'instrument-metadata.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -209,7 +210,7 @@ export class InstrumentMetadataService {
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'instrument-metadata.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
this.active.clear();
|
||||
this.producers.clear();
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { TbankAccount } from './tbank.client';
|
||||
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { supportedTbankAccounts } from './tbank-account.policy';
|
||||
import { TbankErrorCode } from './tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh } from './tbank-source.policy';
|
||||
import { TbankTokenService } from './tbank-token.service';
|
||||
|
||||
@@ -103,7 +104,7 @@ export class TbankAccessService {
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_CONNECTIONS;
|
||||
}
|
||||
@@ -205,7 +206,7 @@ export class TbankAccessService {
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const claimed = await tx.tbankSyncWork.updateMany({
|
||||
where: { ...attempt, state: 'running' },
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
if (!claimed.count) return false;
|
||||
await tx.tbankConnection.update({
|
||||
@@ -236,10 +237,10 @@ export class TbankAccessService {
|
||||
},
|
||||
});
|
||||
if (!connection) {
|
||||
throw new ConflictException({ code: 'TBANK_NOT_CONNECTED' });
|
||||
throw new ConflictException({ code: TbankErrorCode.NotConnected });
|
||||
}
|
||||
if (connection.invalidToken) {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
if (
|
||||
connection.membershipAt &&
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START, MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
import { TbankAccountType } from './tbank-protocol';
|
||||
import { TbankAccount } from './tbank.client';
|
||||
|
||||
const accountTypes = {
|
||||
ACCOUNT_TYPE_TINKOFF: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
ACCOUNT_TYPE_TINKOFF_IIS: {
|
||||
[TbankAccountType.Tinkoff]: { type: 'BROKERAGE', name: 'Брокерский счет' },
|
||||
[TbankAccountType.TinkoffIis]: {
|
||||
type: 'INDIVIDUAL_INVESTMENT',
|
||||
name: 'ИИС',
|
||||
},
|
||||
ACCOUNT_TYPE_INVEST_BOX: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
ACCOUNT_TYPE_INVEST_FUND: {
|
||||
[TbankAccountType.InvestBox]: { type: 'INVEST_BOX', name: 'Инвесткопилка' },
|
||||
[TbankAccountType.InvestFund]: {
|
||||
type: 'MONEY_MARKET_FUND',
|
||||
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,
|
||||
type TbankSyncAttempt,
|
||||
} from './tbank-access.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
@Injectable()
|
||||
@@ -37,7 +38,7 @@ export class TbankMembershipService {
|
||||
.catch(() =>
|
||||
this.logger.event('error', 'tbank.membership.persistence_failed', {
|
||||
userId,
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
}),
|
||||
)
|
||||
.finally(() => this.active.delete(attemptId));
|
||||
@@ -62,7 +63,7 @@ export class TbankMembershipService {
|
||||
} catch (error) {
|
||||
await this.access.failMembership(
|
||||
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 { Environment } from '../../config/env.validation';
|
||||
import { isTbankErrorCode, TbankErrorCode } from './tbank-error-code';
|
||||
import { TbankCrypto } from './tbank.crypto';
|
||||
|
||||
@Injectable()
|
||||
@@ -26,8 +27,8 @@ export class TbankTokenService {
|
||||
try {
|
||||
return await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message === 'INVALID_TBANK_TOKEN') {
|
||||
throw new BadRequestException({ code: 'INVALID_TBANK_TOKEN' });
|
||||
if (error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)) {
|
||||
throw new BadRequestException({ code: TbankErrorCode.InvalidToken });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { status } from '@grpc/grpc-js';
|
||||
import { NotFoundException, ServiceUnavailableException } from '@nestjs/common';
|
||||
|
||||
import { TbankOperationState } from './tbank-protocol';
|
||||
import { TbankClient } from './tbank.client';
|
||||
|
||||
describe('TbankClient operations', () => {
|
||||
@@ -10,7 +11,7 @@ describe('TbankClient operations', () => {
|
||||
to: { seconds: '2' },
|
||||
cursor: 'cursor',
|
||||
limit: 10,
|
||||
state: 'OPERATION_STATE_EXECUTED' as const,
|
||||
state: TbankOperationState.Executed,
|
||||
};
|
||||
|
||||
it('passes a cursor operations request to OperationsService', async () => {
|
||||
|
||||
@@ -29,6 +29,14 @@ import type {
|
||||
import { StructuredLogger } from '../../common/logging/structured-logger.service';
|
||||
import { MILLISECONDS_PER_SECOND } from '../../common/time';
|
||||
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 MIN_RATE_DELAY_MS = 1;
|
||||
@@ -115,7 +123,7 @@ export type TbankOperationsByCursorRequest = {
|
||||
from?: TbankTimestamp;
|
||||
instrumentId?: string;
|
||||
limit?: number;
|
||||
state: 'OPERATION_STATE_EXECUTED';
|
||||
state: TbankOperationState.Executed;
|
||||
to?: TbankTimestamp;
|
||||
};
|
||||
export type TbankOperationsByCursorResponse = {
|
||||
@@ -256,7 +264,7 @@ export class TbankClient {
|
||||
token,
|
||||
this.users,
|
||||
'getAccounts',
|
||||
{ status: 'ACCOUNT_STATUS_OPEN' },
|
||||
{ status: TbankAccountStatus.Open },
|
||||
).then((response) => response.accounts ?? []);
|
||||
}
|
||||
|
||||
@@ -279,14 +287,14 @@ export class TbankClient {
|
||||
token: string,
|
||||
figi: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_FIGI', figi);
|
||||
return this.getInstrument(token, InstrumentIdType.Figi, figi);
|
||||
}
|
||||
|
||||
getInstrumentByUid(
|
||||
token: string,
|
||||
instrumentUid: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
return this.getInstrument(token, 'INSTRUMENT_ID_TYPE_UID', instrumentUid);
|
||||
return this.getInstrument(token, InstrumentIdType.Uid, instrumentUid);
|
||||
}
|
||||
|
||||
getDividends(
|
||||
@@ -314,7 +322,7 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_SHARE',
|
||||
instrumentKind: TbankInstrumentType.Share,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
@@ -326,7 +334,7 @@ export class TbankClient {
|
||||
'findInstrument',
|
||||
{
|
||||
query,
|
||||
instrumentKind: 'INSTRUMENT_TYPE_BOND',
|
||||
instrumentKind: TbankInstrumentType.Bond,
|
||||
},
|
||||
).then((response) => response.instruments ?? []);
|
||||
}
|
||||
@@ -337,7 +345,7 @@ export class TbankClient {
|
||||
this.sparseInstruments,
|
||||
'bondBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -367,7 +375,7 @@ export class TbankClient {
|
||||
this.instruments,
|
||||
'shareBy',
|
||||
{
|
||||
idType: 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType.Uid,
|
||||
id: uid,
|
||||
},
|
||||
).then((response) => response.instrument ?? null);
|
||||
@@ -446,7 +454,7 @@ export class TbankClient {
|
||||
to: {
|
||||
seconds: String(Math.floor(to.valueOf() / MILLISECONDS_PER_SECOND)),
|
||||
},
|
||||
interval: 'CANDLE_INTERVAL_DAY',
|
||||
interval: TbankCandleInterval.Day,
|
||||
limit: 2400,
|
||||
},
|
||||
).then((response) => response.candles ?? []);
|
||||
@@ -454,7 +462,7 @@ export class TbankClient {
|
||||
|
||||
private async getInstrument(
|
||||
token: string,
|
||||
idType: 'INSTRUMENT_ID_TYPE_FIGI' | 'INSTRUMENT_ID_TYPE_UID',
|
||||
idType: InstrumentIdType,
|
||||
id: string,
|
||||
): Promise<null | TbankInstrument> {
|
||||
try {
|
||||
@@ -484,7 +492,7 @@ export class TbankClient {
|
||||
const queueFull = this.queue.size >= MAX_QUEUE_SIZE;
|
||||
try {
|
||||
if (queueFull) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
const response = await this.queue.run(() =>
|
||||
this.request<Response>(token, client, method, request),
|
||||
@@ -498,7 +506,7 @@ export class TbankClient {
|
||||
} catch (error) {
|
||||
const grpcCode = (error as ServiceError).code;
|
||||
const failureContext = queueFull
|
||||
? { code: 'TBANK_UNAVAILABLE' }
|
||||
? { code: TbankErrorCode.Unavailable }
|
||||
: { grpcCode };
|
||||
const retryable = queueFull
|
||||
? metadataCall
|
||||
@@ -534,7 +542,7 @@ export class TbankClient {
|
||||
[status.PERMISSION_DENIED, status.UNAUTHENTICATED].includes(grpcCode)
|
||||
) {
|
||||
// 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 (
|
||||
grpcCode === status.NOT_FOUND &&
|
||||
@@ -542,10 +550,10 @@ export class TbankClient {
|
||||
) {
|
||||
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>(
|
||||
|
||||
@@ -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 { 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 { AuthErrorCode } from './auth-error-code';
|
||||
import { AuthUser, RefreshPayload } from './auth.types';
|
||||
|
||||
const EXPECTED_ROTATED_SESSIONS = 1;
|
||||
@@ -37,7 +40,7 @@ export class AuthService {
|
||||
where: { email },
|
||||
});
|
||||
if (existing) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
|
||||
let user: User;
|
||||
@@ -51,7 +54,7 @@ export class AuthService {
|
||||
});
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new ConflictException({ code: 'EMAIL_ALREADY_REGISTERED' });
|
||||
throw new ConflictException({ code: AuthErrorCode.EmailAlreadyRegistered });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
@@ -70,7 +73,7 @@ export class AuthService {
|
||||
user?.passwordHash ?? DUMMY_PASSWORD_HASH,
|
||||
);
|
||||
if (!user || !valid) {
|
||||
throw new UnauthorizedException({ code: 'INVALID_CREDENTIALS' });
|
||||
throw new UnauthorizedException({ code: AuthErrorCode.InvalidCredentials });
|
||||
}
|
||||
return this.createSession(user);
|
||||
}
|
||||
@@ -84,10 +87,10 @@ export class AuthService {
|
||||
algorithms: ['HS256'],
|
||||
});
|
||||
} catch {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
if (payload.type !== 'refresh') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
const session = await this.prisma.session.findUnique({
|
||||
where: { id: payload.sid },
|
||||
@@ -102,10 +105,10 @@ export class AuthService {
|
||||
if (session && !session.revokedAt) {
|
||||
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)) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return this.rotateSession(session, session.refreshTokenHash);
|
||||
}
|
||||
@@ -142,7 +145,7 @@ export class AuthService {
|
||||
|
||||
async userById(id: string): Promise<User> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -183,7 +186,7 @@ export class AuthService {
|
||||
data,
|
||||
});
|
||||
if (updated.count !== EXPECTED_ROTATED_SESSIONS) {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
} else {
|
||||
await this.prisma.session.update({ where: { id: session.id }, data });
|
||||
@@ -208,6 +211,6 @@ function isUniqueConstraintError(error: unknown): boolean {
|
||||
typeof error === 'object' &&
|
||||
error !== null &&
|
||||
'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 { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { Environment } from '../../../config/env.validation';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
|
||||
@@ -19,7 +20,7 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
|
||||
validate(payload: AccessPayload): AccessPayload {
|
||||
if (payload.type !== 'access') {
|
||||
throw new UnauthorizedException({ code: 'UNAUTHORIZED' });
|
||||
throw new UnauthorizedException({ code: HttpErrorCode.Unauthorized });
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
} from '@nestjs/swagger';
|
||||
|
||||
import { ApiProblemResponses } from '../../../common/decorators/api-safe-error-response.decorator';
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import { AuthService } from '../application/auth.service';
|
||||
import { AccessPayload } from '../application/auth.types';
|
||||
import { CredentialsDto } from './auth.dto';
|
||||
@@ -86,7 +87,7 @@ export class AuthController {
|
||||
) {
|
||||
try {
|
||||
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));
|
||||
} catch (error) {
|
||||
if (error instanceof UnauthorizedException) this.clearCookie(response);
|
||||
|
||||
@@ -22,24 +22,23 @@ import {
|
||||
const NEXT_MONTH_OFFSET = 1;
|
||||
const PREVIOUS_MONTH_LAST_DAY = 0;
|
||||
|
||||
export const BankErrorCode = {
|
||||
InvalidInput: 'BANK_INVALID_INPUT',
|
||||
VersionConflict: 'BANK_VERSION_CONFLICT',
|
||||
NotFound: 'BANK_NOT_FOUND',
|
||||
InvalidMoney: 'BANK_INVALID_MONEY',
|
||||
InvalidDate: 'BANK_INVALID_DATE',
|
||||
ImmutableOperation: 'BANK_IMMUTABLE_OPERATION',
|
||||
InvalidRefund: 'BANK_INVALID_REFUND',
|
||||
RefundLimit: 'BANK_REFUND_LIMIT',
|
||||
DependentRefunds: 'BANK_DEPENDENT_REFUNDS',
|
||||
IntentConflict: 'BANK_INTENT_CONFLICT',
|
||||
OperationDeleted: 'BANK_OPERATION_DELETED',
|
||||
CardArchived: 'BANK_CARD_ARCHIVED',
|
||||
OpeningDateConflict: 'BANK_OPENING_DATE_CONFLICT',
|
||||
WriteConflict: 'BANK_WRITE_CONFLICT',
|
||||
DuplicateCategory: 'BANK_DUPLICATE_CATEGORY',
|
||||
} as const;
|
||||
export type BankErrorCode = (typeof BankErrorCode)[keyof typeof BankErrorCode];
|
||||
export enum BankErrorCode {
|
||||
CardArchived = 'BANK_CARD_ARCHIVED',
|
||||
DependentRefunds = 'BANK_DEPENDENT_REFUNDS',
|
||||
DuplicateCategory = 'BANK_DUPLICATE_CATEGORY',
|
||||
ImmutableOperation = 'BANK_IMMUTABLE_OPERATION',
|
||||
IntentConflict = 'BANK_INTENT_CONFLICT',
|
||||
InvalidDate = 'BANK_INVALID_DATE',
|
||||
InvalidInput = 'BANK_INVALID_INPUT',
|
||||
InvalidMoney = 'BANK_INVALID_MONEY',
|
||||
InvalidRefund = 'BANK_INVALID_REFUND',
|
||||
NotFound = 'BANK_NOT_FOUND',
|
||||
OpeningDateConflict = 'BANK_OPENING_DATE_CONFLICT',
|
||||
OperationDeleted = 'BANK_OPERATION_DELETED',
|
||||
RefundLimit = 'BANK_REFUND_LIMIT',
|
||||
VersionConflict = 'BANK_VERSION_CONFLICT',
|
||||
WriteConflict = 'BANK_WRITE_CONFLICT',
|
||||
}
|
||||
|
||||
export function fail(code: BankErrorCode = BankErrorCode.InvalidInput): never {
|
||||
throw new BadRequestException({ code });
|
||||
|
||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { Prisma } from '../../../../../generated/prisma/client';
|
||||
import { isPrismaErrorCode, PrismaErrorCode } from '../../../../prisma/prisma-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
import {
|
||||
BankErrorCode,
|
||||
@@ -44,15 +45,15 @@ export class BankCardsRepository {
|
||||
? error.code
|
||||
: '';
|
||||
const busy =
|
||||
code === 'P2034' ||
|
||||
code === 'P2028' ||
|
||||
isPrismaErrorCode(code, PrismaErrorCode.WriteConflict) ||
|
||||
isPrismaErrorCode(code, PrismaErrorCode.TransactionFailure) ||
|
||||
(error instanceof Error &&
|
||||
/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;
|
||||
}
|
||||
if (busy) conflict(BankErrorCode.WriteConflict);
|
||||
if (code === 'P2002') conflict(BankErrorCode.DuplicateCategory);
|
||||
if (isPrismaErrorCode(code, PrismaErrorCode.UniqueConstraint)) conflict(BankErrorCode.DuplicateCategory);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
formatDecimal,
|
||||
subtract,
|
||||
} from '../../../common/decimal';
|
||||
import { isTbankProtocolValue, TbankOperationType } from '../../../integrations/tbank/tbank-protocol';
|
||||
import { DateOnlyRange, moscowDate } from '../investment-calendar';
|
||||
import { investmentResourceMeta } from '../investment-resource.mapper';
|
||||
import {
|
||||
@@ -165,7 +166,7 @@ function qualifying(entries: BrokerageOperationDto[]): BrokerageOperationDto[] {
|
||||
return entries.filter(
|
||||
(entry) =>
|
||||
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;
|
||||
const total = values.get(entry.payment.currency) ?? zeroTotal();
|
||||
const value = decimalFromString(entry.payment.value);
|
||||
if (entry.type === 'OPERATION_TYPE_COUPON') {
|
||||
if (isTbankProtocolValue(entry.type, TbankOperationType.Coupon)) {
|
||||
total.coupon = add(total.coupon, value);
|
||||
} else total.dividend = add(total.dividend, value);
|
||||
values.set(entry.payment.currency, total);
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
utcDate,
|
||||
} from '../investment-calendar';
|
||||
|
||||
enum IncomeErrorCode { InvalidPeriod = 'INVALID_INCOME_PERIOD' }
|
||||
|
||||
const MONDAY_WEEKDAY_INDEX = 1;
|
||||
const INCLUSIVE_DAY_OFFSET = 1;
|
||||
const NEXT_DAY_OFFSET = 1;
|
||||
@@ -163,5 +165,5 @@ function yearAgo(range: DateOnlyRange): DateOnlyRange | null {
|
||||
}
|
||||
|
||||
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 { MILLISECONDS_PER_DAY } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
candleWindows,
|
||||
utcDay,
|
||||
} from '../domain/candle-coverage';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import { CandleRepository } from '../infrastructure/candle.repository';
|
||||
import { CatalogEnrichmentService } from './catalog-enrichment.service';
|
||||
import { timestamp } from './instrument-market';
|
||||
@@ -71,8 +73,8 @@ function rangeFor(
|
||||
throw new ServiceUnavailableException({
|
||||
code:
|
||||
input.instrumentType === 'share'
|
||||
? 'STOCK_HISTORY_START_UNAVAILABLE'
|
||||
: 'BOND_HISTORY_START_UNAVAILABLE',
|
||||
? InstrumentErrorCode.StockHistoryStartUnavailable
|
||||
: InstrumentErrorCode.BondHistoryStartUnavailable,
|
||||
});
|
||||
}
|
||||
from.setTime(utcDay(new Date(history)).valueOf());
|
||||
@@ -448,7 +450,7 @@ export class CandleService {
|
||||
const producer = this.queue.shift();
|
||||
if (!producer) break;
|
||||
let success = false;
|
||||
let code = 'TBANK_UNAVAILABLE';
|
||||
let code: string = TbankErrorCode.Unavailable;
|
||||
try {
|
||||
const result = await this.load(producer);
|
||||
if (result === null) {
|
||||
@@ -457,7 +459,7 @@ export class CandleService {
|
||||
}
|
||||
success = result;
|
||||
} 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);
|
||||
}
|
||||
if (error instanceof ServiceUnavailableException) {
|
||||
@@ -523,7 +525,7 @@ export class CandleService {
|
||||
}
|
||||
} catch {
|
||||
this.logger.event('error', 'candles.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
} finally {
|
||||
this.active.clear();
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
type TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { investmentResourceMeta } from '../../investment-resource.mapper';
|
||||
import { InstrumentErrorCode } from '../domain/instrument-error-code';
|
||||
import {
|
||||
CatalogDiscoveryRepository,
|
||||
catalogQuery,
|
||||
@@ -209,8 +211,8 @@ export class CatalogDiscoveryService {
|
||||
throw new ConflictException({
|
||||
code:
|
||||
instrumentType === 'share'
|
||||
? 'STOCK_TICKER_AMBIGUOUS'
|
||||
: 'BOND_TICKER_AMBIGUOUS',
|
||||
? InstrumentErrorCode.StockTickerAmbiguous
|
||||
: InstrumentErrorCode.BondTickerAmbiguous,
|
||||
});
|
||||
}
|
||||
const result = lookup.data
|
||||
@@ -357,11 +359,11 @@ export class CatalogDiscoveryService {
|
||||
);
|
||||
}
|
||||
} 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);
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-discovery.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -395,7 +397,7 @@ export class CatalogDiscoveryService {
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
this.logger.event('error', 'catalog-discovery.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
import { type MoexBondEvent, MoexClient } from '../../../../integrations/moex/moex.api';
|
||||
import { NcrRatingsClient } from '../../../../integrations/ncr/ncr.api';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
@@ -520,11 +521,11 @@ export class CatalogEnrichmentService {
|
||||
}
|
||||
} catch (error) {
|
||||
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);
|
||||
} else {
|
||||
this.logger.event('warn', 'catalog-enrichment.failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -572,7 +573,7 @@ export class CatalogEnrichmentService {
|
||||
this.producers.clear();
|
||||
this.queue.length = 0;
|
||||
this.logger.event('error', 'catalog-enrichment.persistence-failed', {
|
||||
code: 'TBANK_UNAVAILABLE',
|
||||
code: TbankErrorCode.Unavailable,
|
||||
});
|
||||
} finally {
|
||||
this.running = false;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { TbankShare } from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankInstrumentType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
|
||||
export const catalogSearchLimit = 30;
|
||||
|
||||
export function selectCatalogInstruments<T extends TbankShare>(
|
||||
@@ -10,8 +12,8 @@ export function selectCatalogInstruments<T extends TbankShare>(
|
||||
const seen = new Set<string>();
|
||||
const kind =
|
||||
instrumentType === 'share'
|
||||
? 'INSTRUMENT_TYPE_SHARE'
|
||||
: 'INSTRUMENT_TYPE_BOND';
|
||||
? TbankInstrumentType.Share
|
||||
: TbankInstrumentType.Bond;
|
||||
const preferredClass = instrumentType === 'share' ? 'TQBR' : 'TQOB';
|
||||
return found
|
||||
.filter(
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
multiply,
|
||||
} from '../../../../../common/decimal';
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../../../common/time';
|
||||
import { isTbankProtocolValue, TbankCouponType } from '../../../../../integrations/tbank/tbank-protocol';
|
||||
import {
|
||||
TbankAccessService,
|
||||
type TbankBond,
|
||||
@@ -241,10 +242,10 @@ export function bondMetrics(
|
||||
)
|
||||
: null;
|
||||
const couponType =
|
||||
next?.couponType && next.couponType !== 'COUPON_TYPE_UNSPECIFIED'
|
||||
next?.couponType && !isTbankProtocolValue(next.couponType, TbankCouponType.Unspecified)
|
||||
? next.couponType
|
||||
: instrument.floatingCouponFlag === true
|
||||
? 'COUPON_TYPE_FLOATING'
|
||||
? TbankCouponType.Floating
|
||||
: null;
|
||||
return {
|
||||
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 type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type {
|
||||
TbankBond,
|
||||
TbankShare,
|
||||
TbankSyncAttempt,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import type { TbankBond, TbankShare, TbankSyncAttempt } from '../../../../integrations/tbank/tbank.api';
|
||||
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
const UNCHANGED_REVISION_INCREMENT = 0;
|
||||
@@ -462,7 +459,7 @@ export class CatalogDiscoveryRepository {
|
||||
state: 'running',
|
||||
connection: { invalidToken: true },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'INVALID_TBANK_TOKEN' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.InvalidToken },
|
||||
});
|
||||
await tx.tbankSyncWork.updateMany({
|
||||
where: {
|
||||
@@ -472,7 +469,7 @@ export class CatalogDiscoveryRepository {
|
||||
},
|
||||
data: {
|
||||
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 { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import {
|
||||
TbankAccessService,
|
||||
TbankClient,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
|
||||
import { accountHistory } from '../../investment-sync-plan';
|
||||
import {
|
||||
type OperationInterval,
|
||||
@@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService {
|
||||
this.logger.event(
|
||||
'error',
|
||||
'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;
|
||||
@@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService {
|
||||
: {}),
|
||||
cursor,
|
||||
limit: operationsPageSize,
|
||||
state: 'OPERATION_STATE_EXECUTED',
|
||||
state: TbankOperationState.Executed,
|
||||
});
|
||||
if (
|
||||
!(await this.repository.stagePage(attempt, interval, cursor, response))
|
||||
@@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService {
|
||||
} catch (error) {
|
||||
await this.repository.fail(
|
||||
attempt,
|
||||
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
|
||||
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ledgerRange,
|
||||
planInvestmentScreen,
|
||||
} from '../../investment-sync-plan';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import {
|
||||
operationIntervalsOverlap,
|
||||
operationsSyncPriority,
|
||||
@@ -442,7 +443,7 @@ export class BrokerageOperationsService {
|
||||
}
|
||||
return cursor as LocalOperationsCursor;
|
||||
} 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 { MILLISECONDS_PER_SECOND } from '../../../../common/time';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import {
|
||||
TbankMoney,
|
||||
TbankOperationItem,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
|
||||
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
|
||||
import {
|
||||
BrokerageOperationDto,
|
||||
OperationCategory,
|
||||
@@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0;
|
||||
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
|
||||
|
||||
const types: Record<string, OperationCategory> = {
|
||||
OPERATION_TYPE_BUY: 'trade',
|
||||
OPERATION_TYPE_BUY_CARD: 'trade',
|
||||
OPERATION_TYPE_SELL: 'trade',
|
||||
OPERATION_TYPE_SELL_CARD: 'trade',
|
||||
OPERATION_TYPE_BUY_MARGIN: 'trade',
|
||||
OPERATION_TYPE_SELL_MARGIN: 'trade',
|
||||
OPERATION_TYPE_COUPON: 'income',
|
||||
OPERATION_TYPE_DIVIDEND: 'income',
|
||||
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
|
||||
OPERATION_TYPE_DIV_EXT: 'income',
|
||||
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
|
||||
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
|
||||
OPERATION_TYPE_BOND_TAX: 'tax',
|
||||
OPERATION_TYPE_TAX: 'tax',
|
||||
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
|
||||
OPERATION_TYPE_SERVICE_FEE: 'fee',
|
||||
OPERATION_TYPE_MARGIN_FEE: 'fee',
|
||||
OPERATION_TYPE_BROKER_FEE: 'fee',
|
||||
OPERATION_TYPE_SUCCESS_FEE: 'fee',
|
||||
OPERATION_TYPE_INPUT: 'transfer',
|
||||
OPERATION_TYPE_OUTPUT: 'transfer',
|
||||
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
|
||||
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
|
||||
[TbankOperationType.Buy]: 'trade',
|
||||
[TbankOperationType.BuyCard]: 'trade',
|
||||
[TbankOperationType.Sell]: 'trade',
|
||||
[TbankOperationType.SellCard]: 'trade',
|
||||
[TbankOperationType.BuyMargin]: 'trade',
|
||||
[TbankOperationType.SellMargin]: 'trade',
|
||||
[TbankOperationType.Coupon]: 'income',
|
||||
[TbankOperationType.Dividend]: 'income',
|
||||
[TbankOperationType.DividendTransfer]: 'income',
|
||||
[TbankOperationType.DivExt]: 'income',
|
||||
[TbankOperationType.TransIisBs]: 'transfer',
|
||||
[TbankOperationType.TransBsBs]: 'transfer',
|
||||
[TbankOperationType.BondTax]: 'tax',
|
||||
[TbankOperationType.Tax]: 'tax',
|
||||
[TbankOperationType.DividendTax]: 'tax',
|
||||
[TbankOperationType.ServiceFee]: 'fee',
|
||||
[TbankOperationType.MarginFee]: 'fee',
|
||||
[TbankOperationType.BrokerFee]: 'fee',
|
||||
[TbankOperationType.SuccessFee]: 'fee',
|
||||
[TbankOperationType.Input]: 'transfer',
|
||||
[TbankOperationType.Output]: 'transfer',
|
||||
[TbankOperationType.InputSecurities]: 'transfer',
|
||||
[TbankOperationType.OutputSecurities]: 'transfer',
|
||||
};
|
||||
|
||||
export function operationCategory(type: string | undefined): OperationCategory {
|
||||
@@ -51,7 +49,7 @@ export function mapBrokerageOperation(
|
||||
return {
|
||||
id: operation.id ?? operation.cursor ?? '',
|
||||
occurredAt: occurredAt.toISOString(),
|
||||
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
|
||||
type: operation.type ?? TbankOperationType.Unspecified,
|
||||
category: operationCategory(operation.type),
|
||||
description: operation.description?.trim() || operation.name?.trim() || '',
|
||||
figi: text(operation.figi),
|
||||
|
||||
+6
-3
@@ -12,12 +12,15 @@ import type {
|
||||
TbankOperationsByCursorResponse,
|
||||
} 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 {
|
||||
dateOnlyBounds,
|
||||
DateOnlyRange,
|
||||
moscowDate,
|
||||
} from '../../investment-calendar';
|
||||
import { OperationErrorCode } from '../domain/operation-error-code';
|
||||
import {
|
||||
type OperationInterval,
|
||||
operationIntervals,
|
||||
@@ -137,7 +140,7 @@ export class BrokerageOperationsRepository {
|
||||
)
|
||||
.digest('base64url');
|
||||
if (input.cursor && input.cursor.binding !== binding) {
|
||||
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
|
||||
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
|
||||
}
|
||||
const scope = {
|
||||
userId: input.userId,
|
||||
@@ -615,7 +618,7 @@ export class BrokerageOperationsRepository {
|
||||
where: { id: attempt.id },
|
||||
data: {
|
||||
state: 'failed',
|
||||
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
|
||||
errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable,
|
||||
},
|
||||
});
|
||||
if (invalidToken) {
|
||||
@@ -784,7 +787,7 @@ export class BrokerageOperationsRepository {
|
||||
}
|
||||
const rows = staged.filter((row) => {
|
||||
const state = (row.payload as TbankOperationItem).state;
|
||||
return !state || state === 'OPERATION_STATE_EXECUTED';
|
||||
return !state || isTbankProtocolValue(state, TbankOperationState.Executed);
|
||||
});
|
||||
const scope = {
|
||||
userId: attempt.userId,
|
||||
|
||||
@@ -17,6 +17,7 @@ import { RequestContextService } from '../../../../common/logging/request-contex
|
||||
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
|
||||
import { MoneyDto } from '../../../../common/money.dto';
|
||||
import { decimal } from '../../../../integrations/tbank/decimal';
|
||||
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
type MetadataIdentity,
|
||||
@@ -387,7 +388,7 @@ export class PortfolioService {
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Error &&
|
||||
error.message === 'INVALID_TBANK_TOKEN'
|
||||
isTbankErrorCode(error.message, TbankErrorCode.InvalidToken)
|
||||
) {
|
||||
await this.access.rejectMembershipToken(attempt);
|
||||
} else await this.repository.fail(attempt);
|
||||
@@ -412,7 +413,7 @@ export class PortfolioService {
|
||||
async get(userId: string, accountId: string): Promise<PortfolioDto> {
|
||||
const result = await this.read(userId, accountId);
|
||||
if (!result.data) {
|
||||
throw new ServiceUnavailableException({ code: 'TBANK_UNAVAILABLE' });
|
||||
throw new ServiceUnavailableException({ code: TbankErrorCode.Unavailable });
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
@@ -4,10 +4,8 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { Prisma } from '../../../../../generated/prisma/client';
|
||||
import type { PortfolioDto } from '../presentation/portfolio.dto';
|
||||
|
||||
import {
|
||||
tbankSourceNeedsRefresh,
|
||||
tbankSourceTtlMs,
|
||||
} from '../../../../integrations/tbank/tbank.api';
|
||||
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
|
||||
import { tbankSourceNeedsRefresh, tbankSourceTtlMs } from '../../../../integrations/tbank/tbank.api';
|
||||
import { PrismaService } from '../../../../prisma/prisma.service';
|
||||
|
||||
const EXPECTED_UPDATED_ACCOUNTS = 1;
|
||||
@@ -152,7 +150,7 @@ export class PortfolioRepository {
|
||||
state: 'running',
|
||||
connection: { invalidToken: false },
|
||||
},
|
||||
data: { state: 'failed', errorCode: 'TBANK_UNAVAILABLE' },
|
||||
data: { state: 'failed', errorCode: TbankErrorCode.Unavailable },
|
||||
});
|
||||
return result.count === EXPECTED_UPDATED_ACCOUNTS;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
|
||||
import { HttpErrorCode } from '../../../common/error-code';
|
||||
import {
|
||||
supportedTbankAccounts,
|
||||
TbankClient,
|
||||
@@ -29,7 +30,7 @@ export class TbankConnectionService {
|
||||
|
||||
async put(userId: string, value: string) {
|
||||
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(() =>
|
||||
this.tbank.validateToken(token),
|
||||
);
|
||||
|
||||
@@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto';
|
||||
import type { InvestmentResourceMetaDto } from '../investment-resource.dto';
|
||||
|
||||
import { ISO_DATE_LENGTH, ISO_DATE_START } from '../../../common/time';
|
||||
import { TbankErrorCode } from '../../../integrations/tbank/tbank-error-code';
|
||||
import {
|
||||
InstrumentMetadataService,
|
||||
TbankAccessService,
|
||||
@@ -325,7 +326,7 @@ export class TbankSyncService {
|
||||
await this.repository.progress(attempt, children, true);
|
||||
} catch {
|
||||
await this.repository
|
||||
.progress(attempt, children, true, 'TBANK_UNAVAILABLE')
|
||||
.progress(attempt, children, true, TbankErrorCode.Unavailable)
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
UpcomingEventsQueryDto,
|
||||
} from './upcoming-events.dto';
|
||||
|
||||
enum EventsErrorCode { InvalidPeriod = 'INVALID_EVENTS_PERIOD' }
|
||||
|
||||
const FIRST_BATCH_OFFSET = 0;
|
||||
|
||||
const DEFAULT_EVENT_WINDOW_DAYS = 30;
|
||||
@@ -267,7 +269,7 @@ function dateWindow(query: UpcomingEventsQueryDto) {
|
||||
end.setUTCDate(end.getUTCDate() + DEFAULT_EVENT_WINDOW_DAYS);
|
||||
const to = query.to ?? end.toISOString().slice(ISO_DATE_START, ISO_DATE_LENGTH);
|
||||
if (from > to) {
|
||||
throw new BadRequestException({ code: 'INVALID_EVENTS_PERIOD' });
|
||||
throw new BadRequestException({ code: EventsErrorCode.InvalidPeriod });
|
||||
}
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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