diff --git a/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts new file mode 100644 index 0000000..a623894 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/account.mapper.spec.ts @@ -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', + }); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/account.mapper.ts b/apps/backend/src/modules/tbank/mappers/account.mapper.ts new file mode 100644 index 0000000..e8c26e8 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/account.mapper.ts @@ -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, + }; +} diff --git a/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts new file mode 100644 index 0000000..d50b096 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/money.mapper.spec.ts @@ -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'); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/money.mapper.ts b/apps/backend/src/modules/tbank/mappers/money.mapper.ts new file mode 100644 index 0000000..e5400b1 --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/money.mapper.ts @@ -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; +} diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts new file mode 100644 index 0000000..2384dbc --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.spec.ts @@ -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); + }); +}); diff --git a/apps/backend/src/modules/tbank/mappers/operation.mapper.ts b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts new file mode 100644 index 0000000..b7f3e0e --- /dev/null +++ b/apps/backend/src/modules/tbank/mappers/operation.mapper.ts @@ -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(), + }; +}