feat: add tbank domain mappers

This commit is contained in:
Sergey Krylov 2026-06-16 22:20:01 +03:00
parent 7a8bc7c90a
commit 825095106c
6 changed files with 345 additions and 0 deletions

View File

@ -0,0 +1,43 @@
import { isSupportedBrokerAccount, mapAccount } from './account.mapper';
import type { TBankAccount } from '../types/tbank-proto.types';
describe('account.mapper', () => {
const baseAccount: TBankAccount = {
id: '2000000001',
type: 'ACCOUNT_TYPE_TINKOFF',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedDate: { seconds: '1781577000' },
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
};
it('accepts open brokerage and IIS accounts', () => {
expect(isSupportedBrokerAccount(baseAccount)).toBe(true);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_TINKOFF_IIS' })).toBe(
true,
);
});
it('rejects invest box, closed, and unspecified accounts', () => {
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_INVEST_BOX' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, status: 'ACCOUNT_STATUS_CLOSED' })).toBe(
false,
);
expect(isSupportedBrokerAccount({ ...baseAccount, type: 'ACCOUNT_TYPE_UNSPECIFIED' })).toBe(
false,
);
});
it('maps T-Bank account to broker account DTO', () => {
expect(mapAccount(baseAccount)).toEqual({
id: '2000000001',
type: 'brokerage',
name: 'Broker',
status: 'ACCOUNT_STATUS_OPEN',
openedAt: '2026-06-16T02:30:00.000Z',
accessLevel: 'ACCOUNT_ACCESS_LEVEL_FULL_ACCESS',
});
});
});

View File

@ -0,0 +1,22 @@
import { TBANK_ACCOUNT_TYPES, TBANK_OPEN_ACCOUNT_STATUS } from '../tbank.config';
import type { BrokerAccount } from '../types/broker.types';
import type { TBankAccount } from '../types/tbank-proto.types';
import { mapTimestampToIso } from './money.mapper';
export function isSupportedBrokerAccount(account: TBankAccount): boolean {
return (
account.status === TBANK_OPEN_ACCOUNT_STATUS &&
(account.type === TBANK_ACCOUNT_TYPES.brokerage || account.type === TBANK_ACCOUNT_TYPES.iis)
);
}
export function mapAccount(account: TBankAccount): BrokerAccount {
return {
id: account.id,
type: account.type === TBANK_ACCOUNT_TYPES.iis ? 'iis' : 'brokerage',
name: account.name || account.id,
status: account.status,
openedAt: mapTimestampToIso(account.openedDate),
accessLevel: account.accessLevel ?? null,
};
}

View File

@ -0,0 +1,33 @@
import { mapMoneyValue, mapQuotationToNumber, mapTimestampToIso } from './money.mapper';
describe('money.mapper', () => {
it('maps positive MoneyValue with nano precision', () => {
expect(mapMoneyValue({ currency: 'rub', units: '123', nano: 450000000 })).toEqual({
currency: 'RUB',
units: '123',
nano: 450000000,
value: 123.45,
});
});
it('maps negative MoneyValue with negative nano', () => {
expect(mapMoneyValue({ currency: 'rub', units: '-5', nano: -250000000 })).toEqual({
currency: 'RUB',
units: '-5',
nano: -250000000,
value: -5.25,
});
});
it('returns null for absent MoneyValue', () => {
expect(mapMoneyValue(undefined)).toBeNull();
});
it('maps quotation to number', () => {
expect(mapQuotationToNumber({ units: '12', nano: 345000000 })).toBe(12.345);
});
it('maps unix timestamp seconds to ISO string', () => {
expect(mapTimestampToIso({ seconds: '1781577000', nanos: 0 })).toBe('2026-06-16T02:30:00.000Z');
});
});

View File

@ -0,0 +1,45 @@
import type { BrokerMoney } from '../types/broker.types';
import type { TBankMoneyValue, TBankQuotation, TBankTimestamp } from '../types/tbank-proto.types';
const NANO_FACTOR = 1_000_000_000;
export function mapMoneyValue(value: TBankMoneyValue | null | undefined): BrokerMoney | null {
if (!value) return null;
const units = String(value.units ?? '0');
const nano = value.nano ?? 0;
const numericUnits = Number(units);
const decimal = numericUnits + nano / NANO_FACTOR;
return {
currency: (value.currency || '').toUpperCase(),
units,
nano,
value: Number(decimal.toFixed(9)),
};
}
export function mapQuotationToNumber(value: TBankQuotation | null | undefined): number | null {
if (!value) return null;
const units = Number(value.units ?? 0);
const nano = value.nano ?? 0;
return Number((units + nano / NANO_FACTOR).toFixed(9));
}
export function mapTimestampToIso(value: TBankTimestamp | null | undefined): string | null {
if (!value?.seconds) return null;
const millis = Number(value.seconds) * 1000 + Math.floor((value.nanos ?? 0) / 1_000_000);
return new Date(millis).toISOString();
}
export function mapInteger(value: string | number | null | undefined): number | null {
if (value === null || value === undefined || value === '') return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

View File

@ -0,0 +1,64 @@
import { categorizeOperationType, mapOperation, mapOperationsPage } from './operation.mapper';
describe('operation.mapper', () => {
it.each([
['OPERATION_TYPE_BUY', 'trade'],
['OPERATION_TYPE_SELL', 'trade'],
['OPERATION_TYPE_DIVIDEND', 'income'],
['OPERATION_TYPE_COUPON', 'income'],
['OPERATION_TYPE_TAX', 'tax'],
['OPERATION_TYPE_DIVIDEND_TAX', 'tax'],
['OPERATION_TYPE_BROKER_FEE', 'fee'],
['OPERATION_TYPE_SERVICE_FEE', 'fee'],
['OPERATION_TYPE_INPUT', 'transfer'],
['OPERATION_TYPE_OUTPUT', 'transfer'],
['OPERATION_TYPE_UNRECOGNIZED_NEW_VALUE', 'other'],
])('maps %s to %s', (type, category) => {
expect(categorizeOperationType(type)).toBe(category);
});
it('maps operation item with money and quantities', () => {
const result = mapOperation(
{
cursor: 'cursor-1',
brokerAccountId: 'acc-1',
id: 'op-1',
date: { seconds: '1781577000' },
type: 'OPERATION_TYPE_COUPON',
description: 'Coupon',
state: 'OPERATION_STATE_EXECUTED',
ticker: 'SU26238RMFS5',
classCode: 'TQOB',
payment: { currency: 'rub', units: '100', nano: 0 },
commission: { currency: 'rub', units: '0', nano: 0 },
quantity: '5',
quantityDone: '5',
},
'acc-1',
);
expect(result).toMatchObject({
cursor: 'cursor-1',
accountId: 'acc-1',
id: 'op-1',
category: 'income',
ticker: 'SU26238RMFS5',
quantity: 5,
quantityDone: 5,
payment: { currency: 'RUB', value: 100 },
});
});
it('maps operations page cursor metadata', () => {
const page = mapOperationsPage('acc-1', {
hasNext: true,
nextCursor: 'next',
items: [{ cursor: 'cursor-1', type: 'OPERATION_TYPE_BUY' }],
});
expect(page.accountId).toBe('acc-1');
expect(page.hasNext).toBe(true);
expect(page.nextCursor).toBe('next');
expect(page.items).toHaveLength(1);
});
});

View File

@ -0,0 +1,138 @@
import type {
BrokerOperation,
BrokerOperationCategory,
BrokerOperationsPage,
} from '../types/broker.types';
import type {
TBankOperationItem,
TBankOperationsByCursorResponse,
} from '../types/tbank-proto.types';
import { mapInteger, mapMoneyValue, mapTimestampToIso } from './money.mapper';
const TRADE_TYPES = new Set([
'OPERATION_TYPE_BUY',
'OPERATION_TYPE_BUY_CARD',
'OPERATION_TYPE_SELL',
'OPERATION_TYPE_SELL_CARD',
'OPERATION_TYPE_BUY_MARGIN',
'OPERATION_TYPE_SELL_MARGIN',
'OPERATION_TYPE_DELIVERY_BUY',
'OPERATION_TYPE_DELIVERY_SELL',
]);
const INCOME_TYPES = new Set([
'OPERATION_TYPE_DIVIDEND',
'OPERATION_TYPE_COUPON',
'OPERATION_TYPE_BOND_REPAYMENT',
'OPERATION_TYPE_BOND_REPAYMENT_FULL',
'OPERATION_TYPE_OVERNIGHT',
'OPERATION_TYPE_OVER_INCOME',
'OPERATION_TYPE_ACCRUING_VARMARGIN',
'OPERATION_TYPE_TAX_REPO_REFUND',
'OPERATION_TYPE_TAX_REPO_REFUND_PROGRESSIVE',
'OPERATION_TYPE_DIV_EXT',
'OPERATION_TYPE_DFA_REDEMPTION',
]);
const TAX_TYPES = new Set([
'OPERATION_TYPE_TAX',
'OPERATION_TYPE_BOND_TAX',
'OPERATION_TYPE_DIVIDEND_TAX',
'OPERATION_TYPE_TAX_CORRECTION',
'OPERATION_TYPE_BENEFIT_TAX',
'OPERATION_TYPE_TAX_PROGRESSIVE',
'OPERATION_TYPE_BOND_TAX_PROGRESSIVE',
'OPERATION_TYPE_DIVIDEND_TAX_PROGRESSIVE',
'OPERATION_TYPE_BENEFIT_TAX_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO',
'OPERATION_TYPE_TAX_REPO_PROGRESSIVE',
'OPERATION_TYPE_TAX_REPO_HOLD',
'OPERATION_TYPE_TAX_REPO_HOLD_PROGRESSIVE',
'OPERATION_TYPE_TAX_CORRECTION_COUPON',
]);
const FEE_TYPES = new Set([
'OPERATION_TYPE_SERVICE_FEE',
'OPERATION_TYPE_MARGIN_FEE',
'OPERATION_TYPE_BROKER_FEE',
'OPERATION_TYPE_SUCCESS_FEE',
'OPERATION_TYPE_TRACK_MFEE',
'OPERATION_TYPE_TRACK_PFEE',
'OPERATION_TYPE_CASH_FEE',
'OPERATION_TYPE_OUT_FEE',
'OPERATION_TYPE_OUT_STAMP_DUTY',
'OPERATION_TYPE_OUTPUT_PENALTY',
'OPERATION_TYPE_ADVICE_FEE',
'OPERATION_TYPE_OVER_COM',
'OPERATION_TYPE_OTHER_FEE',
'OPERATION_TYPE_FUNDING',
]);
const TRANSFER_TYPES = new Set([
'OPERATION_TYPE_INPUT',
'OPERATION_TYPE_OUTPUT',
'OPERATION_TYPE_INPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SECURITIES',
'OPERATION_TYPE_OUTPUT_SWIFT',
'OPERATION_TYPE_INPUT_SWIFT',
'OPERATION_TYPE_OUTPUT_ACQUIRING',
'OPERATION_TYPE_INPUT_ACQUIRING',
'OPERATION_TYPE_TRANS_IIS_BS',
'OPERATION_TYPE_TRANS_BS_BS',
'OPERATION_TYPE_OUT_MULTI',
'OPERATION_TYPE_INP_MULTI',
'OPERATION_TYPE_OVER_PLACEMENT',
]);
export function categorizeOperationType(type: string | null | undefined): BrokerOperationCategory {
if (!type) return 'other';
if (TRADE_TYPES.has(type)) return 'trade';
if (INCOME_TYPES.has(type)) return 'income';
if (TAX_TYPES.has(type)) return 'tax';
if (FEE_TYPES.has(type)) return 'fee';
if (TRANSFER_TYPES.has(type)) return 'transfer';
return 'other';
}
export function mapOperation(item: TBankOperationItem, accountId: string): BrokerOperation {
const type = item.type || 'OPERATION_TYPE_UNSPECIFIED';
return {
cursor: item.cursor ?? null,
accountId: item.brokerAccountId || accountId,
id: item.id ?? null,
parentOperationId: item.parentOperationId ?? null,
date: mapTimestampToIso(item.date),
type,
category: categorizeOperationType(type),
description: item.description || item.name || null,
state: item.state ?? null,
instrumentUid: item.instrumentUid ?? null,
figi: item.figi ?? null,
ticker: item.ticker ?? null,
classCode: item.classCode ?? null,
instrumentType: item.instrumentType ?? null,
payment: mapMoneyValue(item.payment),
price: mapMoneyValue(item.price),
commission: mapMoneyValue(item.commission),
yield: mapMoneyValue(item.yield),
accruedInt: mapMoneyValue(item.accruedInt),
quantity: mapInteger(item.quantity),
quantityDone: mapInteger(item.quantityDone),
};
}
export function mapOperationsPage(
accountId: string,
response: TBankOperationsByCursorResponse,
): BrokerOperationsPage {
return {
accountId,
items: (response.items ?? []).map((item) => mapOperation(item, accountId)),
nextCursor: response.nextCursor || null,
hasNext: response.hasNext ?? false,
asOf: new Date().toISOString(),
};
}