refactor: simplify backend and guard boundaries #42

Merged
codex-bot merged 14 commits from codex/backend-eslint into develop 2026-10-02 08:05:05 +03:00
47 changed files with 432 additions and 164 deletions
Showing only changes of commit 2d14081d27 - Show all commits
+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'));
});