import { add, type Decimal, decimalFromString, formatDecimal } from '#src/common/decimal'; import { TbankOperationType } from '#src/integrations/tbank/tbank-protocol'; import { moscowDate } from '../../investment-calendar'; const ZERO: Decimal = { value: 0n, scale: 0 }; const ZERO_UNITS = 0n; const NANO_SCALE = 9; const TRANSFER_WINDOW_MS = 300_000; export type PerformanceFlow = { accountId: string; amount: Decimal; at: Date; date: string }; export type Candidate = { accountIds: [string, string]; type: 'cash' }; type Operation = { accountId: string; instrumentUid: null | string; occurredAt: Date; payload: unknown; }; const deposits = new Set([ TbankOperationType.InpMulti, TbankOperationType.Input, TbankOperationType.InputAcquiring, TbankOperationType.InputSwift, ]); const withdrawals = new Set([ TbankOperationType.OutMulti, TbankOperationType.Output, TbankOperationType.OutputAcquiring, TbankOperationType.OutputSwift, ]); const securities = new Set([ TbankOperationType.InputSecurities, TbankOperationType.OutputSecurities, TbankOperationType.TransBsBs, TbankOperationType.TransIisBs, ]); function record(value: unknown): Record { return value !== null && typeof value === 'object' && !Array.isArray(value) ? value as Record : {}; } function money(value: unknown): null | { currency: string; value: Decimal } { const source = record(value); if (typeof source.currency !== 'string') return null; const units = typeof source.units === 'string' || typeof source.units === 'number' ? String(source.units) : '0'; let nano = ZERO_UNITS; if (typeof source.nano === 'number' && Number.isInteger(source.nano)) nano = BigInt(source.nano); if (typeof source.nano === 'string' && /^-?\d+$/.test(source.nano)) nano = BigInt(source.nano); const whole = decimalFromString(units); return { currency: source.currency.toUpperCase(), value: add(whole, { value: nano, scale: NANO_SCALE }), }; } function magnitude(value: Decimal): Decimal { return { value: value.value < ZERO_UNITS ? -value.value : value.value, scale: value.scale }; } function appendCashFlow( operation: Operation, payload: Record, value: null | { currency: string; value: Decimal }, flows: PerformanceFlow[], missingAmountAccounts: Set, ) { if (!value) { missingAmountAccounts.add(operation.accountId); return; } if (value.currency !== 'RUB') return; const amount = magnitude(value.value); flows.push({ accountId: operation.accountId, amount: deposits.has(payload.type as string) ? amount : { value: -amount.value, scale: amount.scale }, date: moscowDate(operation.occurredAt), at: operation.occurredAt, }); } export function readPerformanceOperations(operations: Operation[]) { const flows: PerformanceFlow[] = []; const nonRubAccounts = new Set(); const unvaluedAccounts = new Set(); const missingAmountAccounts = new Set(); for (const operation of operations) { const payload = record(operation.payload); if (typeof payload.type !== 'string') continue; const value = money(payload.payment); if (deposits.has(payload.type) || withdrawals.has(payload.type)) { if (value && value.currency !== 'RUB') nonRubAccounts.add(operation.accountId); appendCashFlow(operation, payload, value, flows, missingAmountAccounts); } else if (securities.has(payload.type)) { // Securities moved in or out carry no money amount for the flow. unvaluedAccounts.add(operation.accountId); } } return { flows, missingAmountAccounts, nonRubAccounts, unvaluedAccounts }; } export function candidateTransfers(input: ReturnType): Candidate[] { const candidates: Candidate[] = []; for (const out of input.flows.filter((flow) => flow.amount.value < ZERO_UNITS)) { for (const incoming of input.flows.filter((flow) => flow.amount.value > ZERO_UNITS)) { if (out.accountId !== incoming.accountId && Math.abs(out.at.valueOf() - incoming.at.valueOf()) <= TRANSFER_WINDOW_MS && formatDecimal(magnitude(out.amount)) === formatDecimal(incoming.amount)) { candidates.push({ accountIds: [out.accountId, incoming.accountId], type: 'cash' }); } } } return candidates; } export function sumFlows(flows: PerformanceFlow[]): Decimal { return flows.reduce((sum, flow) => add(sum, flow.amount), ZERO); }