refactor: enforce enum machine codes
Backend security / security (push) Successful in 8s
Backend security / security (pull_request) Canceled after 0s
Backend verify / verify (push) Canceled after 9s
Backend verify / verify (pull_request) Canceled after 4s
Conventional commits / commits (pull_request) Successful in 31s

Use owner enums for error and T-Bank protocol values with ESLint regression checks.

Preserve open wire fallbacks and keep protocol imports independent of DI and Prisma.

Verified npm ci, verify, 226 unit tests, 262 isolated e2e tests and unchanged OpenAPI.
This commit is contained in:
2026-09-28 13:18:06 +03:00
parent 0303e60b64
commit 2d14081d27
47 changed files with 432 additions and 164 deletions
+10 -4
View File
@@ -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
+2 -1
View File
@@ -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.
+3 -1
View File
@@ -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, а не становятся моделью чистых расчётов.
+43
View File
@@ -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.
+8
View File
@@ -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 -7
View File
@@ -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
); );
} }
+7 -5
View File
@@ -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.
+3 -1
View File
@@ -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);
});
+70
View File
@@ -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;
} }
+2 -1
View File
@@ -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 () => {
+23 -15
View File
@@ -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',
}
+13 -10
View File
@@ -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),
@@ -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 };
} }
+9
View File
@@ -0,0 +1,9 @@
export enum PrismaErrorCode {
TransactionFailure = 'P2028',
UniqueConstraint = 'P2002',
WriteConflict = 'P2034',
}
export function isPrismaErrorCode(value: unknown, expected: PrismaErrorCode): boolean {
return value === expected;
}
+24
View File
@@ -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'));
});