feat: add tbank domain mappers
This commit is contained in:
parent
7a8bc7c90a
commit
825095106c
@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
22
apps/backend/src/modules/tbank/mappers/account.mapper.ts
Normal file
22
apps/backend/src/modules/tbank/mappers/account.mapper.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
33
apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts
Normal file
33
apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
45
apps/backend/src/modules/tbank/mappers/money.mapper.ts
Normal file
45
apps/backend/src/modules/tbank/mappers/money.mapper.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
138
apps/backend/src/modules/tbank/mappers/operation.mapper.ts
Normal file
138
apps/backend/src/modules/tbank/mappers/operation.mapper.ts
Normal 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(),
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user