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
@@ -3,10 +3,9 @@ import { randomUUID } from 'node:crypto';
import { StructuredLogger } from '../../../../common/logging/structured-logger.service';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import {
TbankAccessService,
TbankClient,
} from '../../../../integrations/tbank/tbank.api';
import { isTbankErrorCode, TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { TbankAccessService, TbankClient } from '../../../../integrations/tbank/tbank.api';
import { accountHistory } from '../../investment-sync-plan';
import {
type OperationInterval,
@@ -208,7 +207,7 @@ export class BrokerageOperationsSyncService {
this.logger.event(
'error',
'tbank.operations.sync.persistence_failed',
{ userId: attempt.userId, code: 'TBANK_UNAVAILABLE' },
{ userId: attempt.userId, code: TbankErrorCode.Unavailable },
);
}
const next = pending ? await this.repository.pending(attempt) : null;
@@ -272,7 +271,7 @@ export class BrokerageOperationsSyncService {
: {}),
cursor,
limit: operationsPageSize,
state: 'OPERATION_STATE_EXECUTED',
state: TbankOperationState.Executed,
});
if (
!(await this.repository.stagePage(attempt, interval, cursor, response))
@@ -297,7 +296,7 @@ export class BrokerageOperationsSyncService {
} catch (error) {
await this.repository.fail(
attempt,
error instanceof Error && error.message === 'INVALID_TBANK_TOKEN',
error instanceof Error && isTbankErrorCode(error.message, TbankErrorCode.InvalidToken),
);
return false;
}
@@ -17,6 +17,7 @@ import {
ledgerRange,
planInvestmentScreen,
} from '../../investment-sync-plan';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
operationIntervalsOverlap,
operationsSyncPriority,
@@ -442,7 +443,7 @@ export class BrokerageOperationsService {
}
return cursor as LocalOperationsCursor;
} catch {
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
}
@@ -0,0 +1,3 @@
export enum OperationErrorCode {
InvalidOperationsCursor = 'INVALID_OPERATIONS_CURSOR',
}
@@ -1,10 +1,8 @@
import { formatDecimal } from '../../../../common/decimal';
import { MILLISECONDS_PER_SECOND } from '../../../../common/time';
import { decimal } from '../../../../integrations/tbank/decimal';
import {
TbankMoney,
TbankOperationItem,
} from '../../../../integrations/tbank/tbank.api';
import { TbankOperationType } from '../../../../integrations/tbank/tbank-protocol';
import { TbankMoney, TbankOperationItem } from '../../../../integrations/tbank/tbank.api';
import {
BrokerageOperationDto,
OperationCategory,
@@ -15,29 +13,29 @@ const MISSING_TIMESTAMP_COMPONENT = 0;
const NANOSECONDS_PER_MILLISECOND = 1_000_000;
const types: Record<string, OperationCategory> = {
OPERATION_TYPE_BUY: 'trade',
OPERATION_TYPE_BUY_CARD: 'trade',
OPERATION_TYPE_SELL: 'trade',
OPERATION_TYPE_SELL_CARD: 'trade',
OPERATION_TYPE_BUY_MARGIN: 'trade',
OPERATION_TYPE_SELL_MARGIN: 'trade',
OPERATION_TYPE_COUPON: 'income',
OPERATION_TYPE_DIVIDEND: 'income',
OPERATION_TYPE_DIVIDEND_TRANSFER: 'income',
OPERATION_TYPE_DIV_EXT: 'income',
OPERATION_TYPE_TRANS_IIS_BS: 'transfer',
OPERATION_TYPE_TRANS_BS_BS: 'transfer',
OPERATION_TYPE_BOND_TAX: 'tax',
OPERATION_TYPE_TAX: 'tax',
OPERATION_TYPE_DIVIDEND_TAX: 'tax',
OPERATION_TYPE_SERVICE_FEE: 'fee',
OPERATION_TYPE_MARGIN_FEE: 'fee',
OPERATION_TYPE_BROKER_FEE: 'fee',
OPERATION_TYPE_SUCCESS_FEE: 'fee',
OPERATION_TYPE_INPUT: 'transfer',
OPERATION_TYPE_OUTPUT: 'transfer',
OPERATION_TYPE_INPUT_SECURITIES: 'transfer',
OPERATION_TYPE_OUTPUT_SECURITIES: 'transfer',
[TbankOperationType.Buy]: 'trade',
[TbankOperationType.BuyCard]: 'trade',
[TbankOperationType.Sell]: 'trade',
[TbankOperationType.SellCard]: 'trade',
[TbankOperationType.BuyMargin]: 'trade',
[TbankOperationType.SellMargin]: 'trade',
[TbankOperationType.Coupon]: 'income',
[TbankOperationType.Dividend]: 'income',
[TbankOperationType.DividendTransfer]: 'income',
[TbankOperationType.DivExt]: 'income',
[TbankOperationType.TransIisBs]: 'transfer',
[TbankOperationType.TransBsBs]: 'transfer',
[TbankOperationType.BondTax]: 'tax',
[TbankOperationType.Tax]: 'tax',
[TbankOperationType.DividendTax]: 'tax',
[TbankOperationType.ServiceFee]: 'fee',
[TbankOperationType.MarginFee]: 'fee',
[TbankOperationType.BrokerFee]: 'fee',
[TbankOperationType.SuccessFee]: 'fee',
[TbankOperationType.Input]: 'transfer',
[TbankOperationType.Output]: 'transfer',
[TbankOperationType.InputSecurities]: 'transfer',
[TbankOperationType.OutputSecurities]: 'transfer',
};
export function operationCategory(type: string | undefined): OperationCategory {
@@ -51,7 +49,7 @@ export function mapBrokerageOperation(
return {
id: operation.id ?? operation.cursor ?? '',
occurredAt: occurredAt.toISOString(),
type: operation.type ?? 'OPERATION_TYPE_UNSPECIFIED',
type: operation.type ?? TbankOperationType.Unspecified,
category: operationCategory(operation.type),
description: operation.description?.trim() || operation.name?.trim() || '',
figi: text(operation.figi),
@@ -12,12 +12,15 @@ import type {
TbankOperationsByCursorResponse,
} from '../../../../integrations/tbank/tbank.api';
import { TbankErrorCode } from '../../../../integrations/tbank/tbank-error-code';
import { isTbankProtocolValue, TbankOperationState } from '../../../../integrations/tbank/tbank-protocol';
import { PrismaService } from '../../../../prisma/prisma.service';
import {
dateOnlyBounds,
DateOnlyRange,
moscowDate,
} from '../../investment-calendar';
import { OperationErrorCode } from '../domain/operation-error-code';
import {
type OperationInterval,
operationIntervals,
@@ -137,7 +140,7 @@ export class BrokerageOperationsRepository {
)
.digest('base64url');
if (input.cursor && input.cursor.binding !== binding) {
throw new BadRequestException({ code: 'INVALID_OPERATIONS_CURSOR' });
throw new BadRequestException({ code: OperationErrorCode.InvalidOperationsCursor });
}
const scope = {
userId: input.userId,
@@ -615,7 +618,7 @@ export class BrokerageOperationsRepository {
where: { id: attempt.id },
data: {
state: 'failed',
errorCode: invalidToken ? 'INVALID_TBANK_TOKEN' : 'TBANK_UNAVAILABLE',
errorCode: invalidToken ? TbankErrorCode.InvalidToken : TbankErrorCode.Unavailable,
},
});
if (invalidToken) {
@@ -784,7 +787,7 @@ export class BrokerageOperationsRepository {
}
const rows = staged.filter((row) => {
const state = (row.payload as TbankOperationItem).state;
return !state || state === 'OPERATION_STATE_EXECUTED';
return !state || isTbankProtocolValue(state, TbankOperationState.Executed);
});
const scope = {
userId: attempt.userId,