UNPKG

futoin-xferengine

Version:

Neutral clustered multi-currency transaction engine with limits

1,432 lines (1,213 loc) 76.7 kB
'use strict'; /** * @file * * Copyright 2017 FutoIn Project (https://futoin.org) * Copyright 2017 Andrey Galkin <andrey@futoin.org> * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const assert = require( 'assert' ); const moment = require( 'moment' ); const { SpecTools } = require( 'futoin-invoker' ); const { Mutex } = require( 'futoin-asyncsteps' ); const AmountTools = require( './AmountTools' ); const UUIDTool = require( './UUIDTool' ); const { DB_ACCOUNTS_TABLE, DB_ACCOUNTS_VIEW, DB_XFERS_TABLE, DB_RESERVATIONS_TABLE, EVTGEN_ALIAS, limitStatsTable, historyTimeBarrier, } = require( './main' ); const g_xfer_types = { iface : 'xfertools.types', version : '1.0', }; const TypeSpec = Object.assign( { ftn3rev: '1.9', types: { AccountID: 'string', Fee: { type: 'map', fields: { dst_account : 'AccountID', currency: 'string', amount: 'string', misc_data: { type: 'map', optional: true, }, }, }, PrefixOrFalse: [ 'string', 'boolean' ], XferInfo : { type: 'map', fields: { id : { type: 'string', optional: true, }, src_account: 'AccountID', src_limit_domain: { type: 'string', optional: true, }, src_limit_prefix: "PrefixOrFalse", src_limit_extra: { type: 'map', optional: true, }, dst_account: 'AccountID', dst_limit_domain: { type: 'string', optional: true, }, dst_limit_prefix: "PrefixOrFalse", dst_limit_extra: { type: 'map', optional: true, }, currency: 'string', amount: 'string', preauth: { type: 'boolean', optional: true, }, user_confirm: { type: 'boolean', optional: true, }, reject_mode: { type: 'boolean', optional: true, }, noop: { type: 'boolean', optional: true, }, type: 'string', ext_id : { type: 'string', optional: true, }, misc_data: { type: 'map', optional: true, }, orig_ts: { type: 'string', optional: true, }, extra_fee: { type: 'Fee', optional: true, }, xfer_fee: { type: 'Fee', optional: true, }, use_preauth: { type: "string", optional: true, }, }, }, }, }, g_xfer_types ); const g_load_mutex = new Mutex(); const BY_EXT_ID = Symbol( 'by-ext-id' ); const BY_FEE_ID = Symbol( 'by-fee-id' ); const BY_XFER_ID = Symbol( 'by-xfer-id' ); const ACC_INFO = Symbol( 'get-account-info' ); /** * Actual transaction core * @private */ class XferTools { get ST_WAIT_USER() { return 'WaitUser'; } get ST_WAIT_EXT_IN() { return 'WaitExtIn'; } get ST_WAIT_EXT_OUT() { return 'WaitExtOut'; } get ST_DONE() { return 'Done'; } get ST_CANCELED() { return 'Canceled'; } get ST_REJECTED() { return 'Rejected'; } get ACCT_SYSTEM() { return 'System'; } get ACCT_REGULAR() { return 'Regular'; } get ACCT_EXTERNAL() { return 'External'; } get ACCT_TRANSIT() { return 'Transit'; } get ACCT_BONUS() { return 'Bonus'; } constructor( ccm, domain ) { this._ccm = ccm; this._domain = domain; } get db() { return this._ccm.db( 'xfer' ); } // Processing of Limits & Stats //----------------------- _processLimits( as, dbxfer, domain, holder, delta_currency, deltas ) { const ccm = this._ccm; const limface = ccm.iface( 'xfer.limits' ); const acctface = ccm.iface( 'xfer.accounts' ); const currface = ccm.iface( 'currency.info' ); let currency; let currency_info; let stats; let lim_hard; let lim_check; let lim_risk; // Limits statistics acctface.getLimitStats( as, holder, domain ); as.add( ( as, res ) => { currency = res.currency; stats = res.stats; currface.getCurrency( as, currency ); as.add( ( as, res ) => { currency_info = res; if ( delta_currency && ( currency !== delta_currency ) ) { // expect stats limits in "base currency" currface.getExRate( as, currency, delta_currency ); as.add( ( as, { rate } ) => { rate = AmountTools.backRate( rate ); // round up amounts deltas = AmountTools.convAllAmounts( deltas, rate, currency_info.dec_places, true ); } ); } } ); } ); // Global limits acctface.getAccountHolder( as, holder ); as.add( ( as, res ) => { limface.getLimits( as, res.group, domain ); } ); as.add( ( as, limits ) => { lim_hard = limits.hard; lim_check = limits.check; lim_risk = limits.risk; const lim_currency = limits.currency; if ( currency !== lim_currency ) { // expect global limits in "main base currency" currface.getExRate( as, lim_currency, currency ); as.add( ( as, { rate } ) => { rate = AmountTools.backRate( rate ); // round down limits lim_hard = AmountTools.convAllAmounts( lim_hard, rate, currency_info.dec_places, false ); lim_check = AmountTools.convAllAmounts( lim_check, rate, currency_info.dec_places, false ); lim_risk = AmountTools.convAllAmounts( lim_risk, rate, currency_info.dec_places, false ); } ); } } ); // as.add( ( as ) => { const new_stats = AmountTools.prepNewStats( stats, deltas ); if ( !AmountTools.checkStatsLimit( new_stats, lim_hard ) ) { if ( 'limithit_daily_cnt' in deltas ) { // inner loop as.error( 'LimitReject' ); } as.add( ( as ) => { if ( 'failure_daily_cnt' in deltas ) { // no need to update limit-hit on failure limit as.error( 'LimitReject' ); } const dbxfer2 = this.db.newXfer(); this._processLimits( as, dbxfer2, 'Misc', holder, null, { limithit_daily_cnt: 1, limithit_weekly_cnt: 1, limithit_monthly_cnt: 1, } ); as.add( ( as ) => dbxfer2.execute( as ) ); }, ( as, err ) => { if ( err === 'LimitReject' ) { acctface.call( as, 'updateAccountHolder', { id: holder, enabled: false, } ); } } ); as.add( ( as ) => as.error( 'LimitReject' ) ); return; } const do_check = lim_check && !AmountTools.checkStatsLimit( new_stats, lim_check ); const do_risk = lim_risk && !AmountTools.checkStatsLimit( new_stats, lim_risk ); const dec_places = currency_info.dec_places; const lq = dbxfer.update( limitStatsTable( domain ), { affected: 1 } ); lq.where( { holder } ); for ( let k in deltas ) { let dv = deltas[k]; if ( k in stats ) { let lv = lim_hard[k]; if ( AmountTools.isAmountField( k ) ) { dv = AmountTools.toStorage( dv, dec_places ); lv = AmountTools.toStorage( lv, dec_places ); } // Next value based on ACTUAL transactional data ! const val = `${k} + ${lq.escape( dv )}`; lq.set( k, lq.expr( val ) ); // Double check in database based on ACTUAL data lq.where( `(${val}) <= ${lq.escape( lv )}` ); } } as.success( { do_check, do_risk } ); } ); } addLimitProcessing( as, dbxfer, domain, holder, currency, amount, prefix, extra={} ) { let deltas = prefix ? { [`${prefix}_min_amt`] : amount, [`${prefix}_daily_amt`] : amount, [`${prefix}_daily_cnt`] : 1, [`${prefix}_weekly_amt`] : amount, [`${prefix}_weekly_cnt`] : 1, [`${prefix}_monthly_amt`] : amount, [`${prefix}_monthly_cnt`] : 1, } : {}; Object.assign( deltas, extra ); this._processLimits( as, dbxfer, domain, holder, currency, deltas ); } // Reverting transaction stats //----------------------- _cancelStats( as, dbxfer, domain, holder, date, delta_currency, deltas ) { const ccm = this._ccm; const acctface = ccm.iface( 'xfer.accounts' ); const currface = ccm.iface( 'currency.info' ); let currency; let currency_info; // Limits statistics acctface.getLimitStats( as, holder, domain ); as.add( ( as, res ) => { currency = res.currency; currface.getCurrency( as, currency ); as.add( ( as, res ) => { currency_info = res; if ( currency !== delta_currency ) { // expect stats limits in "base currency" currface.getExRate( as, currency, delta_currency ); as.add( ( as, { rate } ) => { rate = AmountTools.backRate( rate ); // round up amounts deltas = AmountTools.convAllAmounts( deltas, rate, currency_info.dec_places, true ); } ); } } ); } ); // as.add( ( as ) => { const dec_places = currency_info.dec_places; date = moment.utc( date ); const now = moment.utc(); const do_daily = date.format( 'YYYY-MM-DD' ) === now.format( 'YYYY-MM-DD' ); const do_weekly = now.startOf( 'week' ).isSameOrBefore( date ); const do_monthly = now.startOf( 'month' ).isSameOrBefore( date ); if ( !do_daily && !do_weekly && !do_monthly ) { return; } const lq = dbxfer.update( limitStatsTable( domain ) ); lq.where( { holder } ); const q_zero = lq.escape( 0 ); for ( let k in deltas ) { let dv = deltas[k]; if ( !do_daily && ( k.indexOf( '_daily_' ) > 0 ) ) { continue; } if ( !do_weekly && ( k.indexOf( '_weekly_' ) > 0 ) ) { continue; } if ( !do_monthly && ( k.indexOf( '_monthly_' ) > 0 ) ) { continue; } if ( AmountTools.isAmountField( k ) ) { dv = AmountTools.toStorage( dv, dec_places ); } // Next value based on ACTUAL transactional data ! const val = `${k} - ${lq.escape( dv )}`; lq.set( k, lq.expr( val ) ); // Double check in database based on ACTUAL data lq.where( `(${val}) >= ${q_zero}` ); } } ); } addStatsCancel( as, dbxfer, domain, holder, date, currency, amount, prefix, extra={} ) { // cancel must be accounted as transaction for security reasons // cnt - (-1) = cnt + 1 let deltas = prefix ? { [`${prefix}_daily_amt`] : amount, [`${prefix}_daily_cnt`] : -1, [`${prefix}_weekly_amt`] : amount, [`${prefix}_weekly_cnt`] : -1, [`${prefix}_monthly_amt`] : amount, [`${prefix}_monthly_cnt`] : -1, } : {}; Object.assign( deltas, extra ); this._cancelStats( as, dbxfer, domain, holder, date, currency, deltas ); } // Ttransaction processing //----------------------- makeExtId( rel_account, rel_ext_id ) { return `${rel_ext_id}:${rel_account}`; } _checkExisting( as, xfer ) { const ext_id = xfer.ext_id; if ( ext_id ) { //--- if ( !ext_id.endsWith( xfer.src_account ) && !ext_id.endsWith( xfer.dst_account ) ) { as.error( 'XferError', 'Invalid external ID format' ); } //--- const barrier = historyTimeBarrier(); if ( !xfer.orig_ts || barrier.isAfter( xfer.orig_ts ) ) { as.error( 'OriginalTooOld' ); } //--- this.db .getPrepared( BY_EXT_ID, ( db ) => { const q = db.select( DB_XFERS_TABLE ); q.where( 'ext_id', q.param( 'ext_id' ) ); return q.prepare(); } ) .executeAssoc( as, { ext_id } ); } else if ( xfer.id ) { this.db .getPrepared( BY_XFER_ID, ( db ) => { const q = db.select( DB_XFERS_TABLE ); q.where( 'uuidb64', q.param( 'id' ) ); return q.prepare(); } ) .executeAssoc( as, { id: xfer.id } ); } else { return; } as.add( ( as, rows ) => { if ( rows.length <= 0 ) { if ( xfer.user_confirm || xfer.reject_mode ) { as.error( 'UnknownXferID' ); } return; } const r = rows[0]; r.src_amount = AmountTools.fromStorage( r.src_amount, xfer.src_info.dec_places ); r.dst_amount = AmountTools.fromStorage( r.dst_amount, xfer.dst_info.dec_places ); xfer.orig_misc_data = r.misc_data; r.misc_data = JSON.parse( r.misc_data ); if ( ( xfer.src_account !== r.src ) || ( xfer.src_info.currency_id !== r.src_currency_id ) || ( xfer.dst_account !== r.dst ) || ( xfer.dst_info.currency_id !== r.dst_currency_id ) || ( xfer.type !== r.xfer_type ) ) { as.error( "OriginalMismatch", 'Account/Currency/Type' ); } if ( ( xfer.src_info.currency !== xfer.currency ) && ( xfer.dst_info.currency !== xfer.currency ) ) { if ( xfer.currency !== r.misc_data.currency ) { as.error( "OriginalMismatch", 'Currency' ); } if ( !AmountTools.isEqual( xfer.amount, r.misc_data.amount ) ) { as.error( "OriginalMismatch", 'Currency' ); } // Make sure to use original exrate xfer.src_amount = r.src_amount; xfer.dst_amount = r.dst_amount; } else if ( xfer.src_info.currency === xfer.currency ) { if ( !AmountTools.isEqual( xfer.amount, r.src_amount ) ) { as.error( "OriginalMismatch", 'Currency' ); } // Make sure to use original exrate xfer.dst_amount = r.dst_amount; } else { // if ( xfer.dst_info.currency === xfer.currency ) if ( !AmountTools.isEqual( xfer.amount, r.dst_amount ) ) { as.error( "OriginalMismatch", 'Currency' ); } // Make sure to use original exrate xfer.src_amount = r.src_amount; } xfer.id = r.uuidb64; xfer.ext_id = r.ext_id; xfer.status = r.xfer_status; xfer.created = moment.utc( r.created ).format(); xfer.updated = moment.utc( r.updated ).format(); Object.assign( xfer.misc_data, r.misc_data ); xfer.repeat = true; if ( xfer.misc_data.rel_in_id ) { xfer.in_xfer.id = xfer.misc_data.rel_in_id; this._readTransitXfer( as, xfer.in_xfer ); } if ( xfer.misc_data.rel_out_id ) { xfer.out_xfer.id = xfer.misc_data.rel_out_id; this._readTransitXfer( as, xfer.out_xfer ); } if ( r.extra_fee_id ) { if ( !xfer.extra_fee ) { as.error( "OriginalMismatch", 'ExtraFee' ); } xfer.extra_fee.id = r.extra_fee_id; xfer.extra_fee.src_account = xfer.src_account; xfer.extra_fee.force = xfer.force; xfer.extra_fee.in_cancel = xfer.in_cancel; this._readFeeXfer( as, xfer.extra_fee ); } if ( r.xfer_fee_id ) { if ( !xfer.xfer_fee ) { as.error( "OriginalMismatch", 'Fee' ); } xfer.xfer_fee.id = r.xfer_fee_id; xfer.xfer_fee.src_account = xfer.dst_account; xfer.xfer_fee.force = xfer.force; xfer.xfer_fee.in_cancel = xfer.in_cancel; this._readFeeXfer( as, xfer.xfer_fee ); } } ); } _readTransitXfer( as, xfer ) { this._getAccountsInfo( as, xfer ); this._convXferAmounts( as, xfer ); this.db .getPrepared( BY_FEE_ID, ( db ) => { const q = db.select( DB_XFERS_TABLE ); q.where( 'uuidb64', q.param( 'uuidb64' ) ); return q.prepare(); } ) .executeAssoc( as, { uuidb64: xfer.id } ); as.add( ( as, rows ) => { if ( !rows.length ) { as.error( 'XferError', 'Missing transit xfer' ); } const r = rows[0]; if ( ( xfer.src_account !== r.src ) || ( xfer.src_info.currency_id !== r.src_currency_id ) || ( xfer.dst_account !== r.dst ) || ( xfer.dst_info.currency_id !== r.dst_currency_id ) || ( xfer.type !== r.xfer_type ) ) { as.error( "OriginalMismatch" ); } xfer.status = r.xfer_status; xfer.created = moment.utc( r.created ).format(); xfer.updated = moment.utc( r.updated ).format(); xfer.orig_misc_data = r.misc_data; Object.assign( xfer.misc_data, JSON.parse( r.misc_data ) ); // fee amounts may change on repeat calls - use original xfer.src_amount = AmountTools.fromStorage( r.src_amount, xfer.src_info.dec_places ); xfer.dst_amount = AmountTools.fromStorage( r.dst_amount, xfer.dst_info.dec_places ); xfer.repeat = true; } ); } _readFeeXfer( as, fee_xfer ) { fee_xfer.type = 'Fee'; fee_xfer.misc_data = fee_xfer.misc_data || {}; this._getAccountsInfo( as, fee_xfer ); this._convXferAmounts( as, fee_xfer ); this._prepareTransit( as, fee_xfer ); this.db .getPrepared( BY_FEE_ID, ( db ) => { const q = db.select( DB_XFERS_TABLE ); q.where( 'uuidb64', q.param( 'uuidb64' ) ); return q.prepare(); } ) .executeAssoc( as, { uuidb64: fee_xfer.id } ); as.add( ( as, rows ) => { if ( !rows.length ) { as.error( 'XferError', 'Missing fee xfer' ); } const r = rows[0]; if ( ( fee_xfer.src_account !== r.src ) || ( fee_xfer.src_info.currency_id !== r.src_currency_id ) || ( fee_xfer.dst_account !== r.dst ) || ( fee_xfer.dst_info.currency_id !== r.dst_currency_id ) || ( fee_xfer.type !== r.xfer_type ) ) { as.error( "OriginalMismatch" ); } fee_xfer.status = r.xfer_status; fee_xfer.created = moment.utc( r.created ).format(); fee_xfer.updated = moment.utc( r.updated ).format(); fee_xfer.orig_misc_data = r.misc_data; Object.assign( fee_xfer.misc_data, JSON.parse( r.misc_data ) ); // fee amounts may change on repeat calls - use original fee_xfer.src_amount = AmountTools.fromStorage( r.src_amount, fee_xfer.src_info.dec_places ); fee_xfer.dst_amount = AmountTools.fromStorage( r.dst_amount, fee_xfer.dst_info.dec_places ); fee_xfer.repeat = true; if ( fee_xfer.misc_data.rel_in_id ) { fee_xfer.in_xfer.id = fee_xfer.misc_data.rel_in_id; this._readTransitXfer( as, fee_xfer.in_xfer ); } } ); } _getAccountsInfo( as, xfer ) { this.db .getPrepared( ACC_INFO, ( db ) => { const q = db.select( DB_ACCOUNTS_VIEW ); q.where( 'uuidb64 IN', [ q.param( 'src' ), q.param( 'dst' ), ] ); return q.prepare(); } ) .executeAssoc( as, { src: xfer.src_account, dst: xfer.dst_account, } ); as.add( ( as, rows ) => { if ( ( xfer.src_account === xfer.dst_account ) && rows.length ) { // artificial case rows.push( Object.assign( {}, rows[0] ) ); } if ( rows.length !== 2 ) { as.error( 'UnknownAccountID' ); } for ( let i = 0; i < 2; ++i ) { AmountTools.accountFromStorage( rows[i] ); } if ( rows[0].uuidb64 === xfer.src_account ) { xfer.src_info = rows[0]; xfer.dst_info = rows[1]; } else { xfer.src_info = rows[1]; xfer.dst_info = rows[0]; } if ( xfer.force || xfer.in_cancel ) { // ignore } else if ( ( xfer.src_info.account_enabled !== 'Y' ) || ( xfer.src_info.holder_enabled !== 'Y' ) ) { as.error( 'DisabledAccount' ); } } ); } _convXferAmounts( as, xfer ) { as.add( ( as ) => { // common if ( ( xfer.src_info.currency !== xfer.currency ) || ( xfer.dst_info.currency !== xfer.currency ) ) { xfer.misc_data.amount = xfer.amount; xfer.misc_data.currency = xfer.currency; } const currinfo = this._ccm.iface( 'currency.info' ); // src -> buy rate & round up if ( xfer.src_info.currency === xfer.currency ) { xfer.src_amount = xfer.amount; } else { currinfo.getExRate( as, xfer.currency, xfer.src_info.currency ); as.add( ( as, { rate, margin } ) => { rate = AmountTools.buyRate( rate, margin ); xfer.src_amount = AmountTools.convAmount( xfer.amount, rate, xfer.src_info.dec_places, true ); } ); } // dst -> sell rate & round down if ( xfer.dst_info.currency === xfer.currency ) { xfer.dst_amount = xfer.amount; } else { currinfo.getExRate( as, xfer.currency, xfer.dst_info.currency ); as.add( ( as, { rate, margin } ) => { rate = AmountTools.sellRate( rate, margin ); xfer.dst_amount = AmountTools.convAmount( xfer.amount, rate, xfer.dst_info.dec_places, false ); } ); } } ); } _prepareTransit( as, xfer ) { as.add( ( as ) => { if ( xfer.in_transit ) { return; } if ( xfer.src_info.acct_type === this.ACCT_TRANSIT ) { xfer.in_xfer = { id: xfer.misc_data.rel_in_id, src_account: xfer.src_info.rel_uuidb64, src_limit_domain: 'Payments', src_limit_prefix: 'outbound', dst_account: xfer.src_account, currency: xfer.src_info.currency, amount: xfer.src_amount, type: xfer.type, misc_data: Object.assign( {}, xfer.misc_data, { xfer_id: xfer.id, } ), in_transit: true, force: xfer.force, in_cancel: xfer.in_cancel, }; } if ( ( xfer.dst_info.acct_type === this.ACCT_TRANSIT ) && !xfer.preauth ) { xfer.out_xfer = { id: xfer.misc_data.rel_out_id, src_account: xfer.dst_account, dst_account: xfer.dst_info.rel_uuidb64, src_limit_domain: 'Payments', src_limit_prefix: 'inbound', currency: xfer.dst_info.currency, amount: xfer.dst_amount, type: xfer.type, misc_data: Object.assign( {}, xfer.misc_data, { xfer_id: xfer.id, } ), in_transit: true, force: xfer.force, in_cancel: xfer.in_cancel, }; } } ); } _checkXferLimits( as, dbxfer, xfer ) { as.add( ( as ) => { // Check if enough balance, if not Transit account if ( xfer.src_info.acct_type !== this.ACCT_TRANSIT && xfer.src_info.acct_type !== this.ACCT_SYSTEM && !xfer.force ) { let req_amt = xfer.src_amount; if ( xfer.extra_fee ) { req_amt = AmountTools.add( req_amt, xfer.extra_fee.src_amount, xfer.src_info.dec_places ); } if ( !AmountTools.checkXferAmount( req_amt, xfer.src_info, xfer.preauth_amount ) ) { as.error( 'NotEnoughFunds' ); } } const misc_data = xfer.misc_data; if ( xfer.src_limit_prefix || xfer.src_limit_extra ) { misc_data.src_limit = { domain: xfer.src_limit_domain || this._domain, prefix: xfer.src_limit_prefix, extra: xfer.src_limit_extra || {}, }; this.addLimitProcessing( as, dbxfer, misc_data.src_limit.domain, xfer.src_info.holder, xfer.src_info.currency, xfer.src_amount, misc_data.src_limit.prefix, misc_data.src_limit.extra ); as.add( ( as, { do_check, do_risk } ) => { misc_data.do_check = misc_data.do_check || do_check; misc_data.do_risk = misc_data.do_risk || do_risk; } ); } if ( xfer.dst_limit_prefix || xfer.dst_limit_extra ) { misc_data.dst_limit = { domain: xfer.dst_limit_domain || this._domain, prefix: xfer.dst_limit_prefix, extra: xfer.dst_limit_extra || {}, }; this.addLimitProcessing( as, dbxfer, misc_data.dst_limit.domain, xfer.dst_info.holder, xfer.dst_info.currency, xfer.dst_amount, misc_data.dst_limit.prefix, misc_data.dst_limit.extra ); as.add( ( as, { do_risk } ) => { // NO user confirmation on destination check limits //misc_data.do_check = misc_data.do_check || do_check; misc_data.do_risk = misc_data.do_risk || do_risk; } ); } } ); } _checkCancel( as, xfer ) { // Check if enough balance, if not Transit account if ( xfer.dst_info.acct_type !== this.ACCT_TRANSIT && xfer.dst_info.acct_type !== this.ACCT_SYSTEM && ( xfer.status !== this.ST_CANCELED ) && ! xfer.force ) { let cancel_amt = xfer.dst_amount; if ( xfer.xfer_fee ) { cancel_amt = AmountTools.subtract( cancel_amt, xfer.xfer_fee.src_amount, xfer.dst_info.dec_places ); } if ( !AmountTools.checkXferAmount( cancel_amt, xfer.dst_info ) ) { as.error( 'NotEnoughFunds' ); } } } _cancelLimits( as, dbxfer, xfer ) { const misc_data = xfer.misc_data; if ( misc_data.src_limit && misc_data.src_limit.prefix ) { this.addStatsCancel( as, dbxfer, misc_data.src_limit.domain, xfer.src_info.holder, xfer.orig_ts, xfer.src_info.currency, xfer.src_amount, misc_data.src_limit.prefix, misc_data.src_limit.extra ); } if ( misc_data.dst_limit && misc_data.dst_limit.prefix ) { this.addStatsCancel( as, dbxfer, misc_data.dst_limit.domain, xfer.dst_info.holder, xfer.orig_ts, xfer.dst_info.currency, xfer.dst_amount, misc_data.dst_limit.prefix, misc_data.dst_limit.extra ); } } _analyzeXferRisk( as, xfer ) { as.add( ( as ) => { if ( !xfer.misc_data.do_risk ) { return; } // TODO: call external risk analysis service as.error( 'TODO', 'Risk analysis' ); } ); } _genRelIDs( as, dbxfer, xfer ) { as.add( ( as ) => { if ( xfer.in_xfer ) { if ( !xfer.in_xfer.id ) { const rel_in_id = UUIDTool.genXfer( dbxfer ); xfer.misc_data.rel_in_id = rel_in_id; xfer.in_xfer.id = rel_in_id; xfer.in_xfer.misc_data.xfer_id = xfer.id; } } if ( xfer.out_xfer ) { if ( !xfer.out_xfer.id ) { // new case const rel_out_id = UUIDTool.genXfer( dbxfer ); xfer.misc_data.rel_out_id = rel_out_id; xfer.out_xfer.id = rel_out_id; xfer.out_xfer.misc_data.xfer_id = xfer.id; } } } ); } _createTransitInbound( as, dbxfer, xfer ) { as.add( ( as ) => { if ( !xfer.in_xfer ) { return; } xfer.status = this.ST_WAIT_EXT_IN; const in_xfer = xfer.in_xfer; in_xfer.status = xfer.status; this._startXfer( as, dbxfer, in_xfer ); } ); } _createTransitOutbound( as, dbxfer, xfer ) { as.add( ( as ) => { if ( !xfer.out_xfer ) { return; } const out_xfer = xfer.out_xfer; out_xfer.status = xfer.status; this._startXfer( as, dbxfer, out_xfer ); } ); } _decreaseBalance( dbxfer, xfer, cancel=false ) { let acct_info; let account; let balance_field; let q_amt; if ( cancel && xfer.preauth ) { return; } const xfer_q = dbxfer.select( DB_XFERS_TABLE, { selected: 1 } ) .where( 'uuidb64', xfer.id ); //--- const acct_q = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ); if ( cancel ) { acct_info = xfer.dst_info; account = xfer.dst_account; balance_field = 'dst_post_balance'; xfer_q.get( [ 'dst', 'dst_amount', 'dst_currency_id' ] ); xfer_q.where( 'dst_post_balance IS NOT NULL' ); q_amt = acct_q.backref( xfer_q, 'dst_amount' ); acct_q.where( 'uuidb64', acct_q.backref( xfer_q, 'dst' ) ); acct_q.where( 'currency_id', acct_q.backref( xfer_q, 'dst_currency_id' ) ); } else { acct_info = xfer.src_info; account = xfer.src_account; balance_field = 'src_post_balance'; xfer_q.get( [ 'src', 'src_amount', 'src_currency_id' ] ); xfer_q.where( 'src_post_balance IS NULL' ); q_amt = acct_q.backref( xfer_q, 'src_amount' ); acct_q.where( 'uuidb64', acct_q.backref( xfer_q, 'src' ) ); acct_q.where( 'currency_id', acct_q.backref( xfer_q, 'src_currency_id' ) ); } const q_zero = dbxfer.escape( '0' ); acct_q.set( 'updated', dbxfer.helpers().now() ); if ( xfer.preauth ) { acct_q.set( 'reserved', dbxfer.expr( `reserved + ${q_amt}` ) ); } else { acct_q.set( 'balance', dbxfer.expr( `balance - ${q_amt}` ) ); } acct_q.where( 'uuidb64', account ); // double safety acct_q.where( 'currency_id', acct_info.currency_id ); // double safety if ( ( acct_info.acct_type !== this.ACCT_SYSTEM ) && !xfer.force ) { acct_q.where( `(balance + COALESCE(overdraft, ${q_zero}) - reserved - ${q_amt}) >= 0` ); } //--- if ( xfer.preauth ) { assert( xfer.ext_id ); const reserve_q = dbxfer.insert( DB_RESERVATIONS_TABLE, { affected: 1 } ); reserve_q.set( { ext_id: xfer.ext_id || null, account: reserve_q.backref( xfer_q, 'src' ), currency_id: reserve_q.backref( xfer_q, 'src_currency_id' ), amount: reserve_q.backref( xfer_q, 'src_amount' ), created: reserve_q.helpers().now(), } ); } this._recordBalance( dbxfer, xfer, account, balance_field, acct_info.dec_places ); } _increaseBalance( dbxfer, xfer, cancel=false ) { if ( !cancel && xfer.preauth ) { return; } let acct_info; let account; let balance_field; const xfer_q = dbxfer.select( DB_XFERS_TABLE, { selected: 1 } ) .where( 'uuidb64', xfer.id ); const acct_q = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ); if ( cancel ) { acct_info = xfer.src_info; account = xfer.src_account; balance_field = 'src_post_balance'; xfer_q.get( [ 'src', 'src_amount', 'src_currency_id' ] ); xfer_q.where( 'xfer_status', this.ST_CANCELED ); xfer_q.where( 'src_post_balance IS NOT NULL' ); if ( xfer.preauth ) { acct_q.set( 'reserved', acct_q.expr( `reserved - ${acct_q.backref( xfer_q, 'src_amount' )}` ) ); } else { acct_q.set( 'balance', acct_q.expr( `balance + ${acct_q.backref( xfer_q, 'src_amount' )}` ) ); } acct_q.set( 'updated', dbxfer.helpers().now() ); acct_q.where( 'uuidb64', acct_q.backref( xfer_q, 'src' ) ); acct_q.where( 'currency_id', acct_q.backref( xfer_q, 'src_currency_id' ) ); if ( xfer.preauth ) { acct_q.where( 'reserved >=', acct_q.backref( xfer_q, 'src_amount' ) ); // NOTE: it's only allowed to cancel full pre-auth - we check current amount const reserve_q = dbxfer.update( DB_RESERVATIONS_TABLE, { affected: 1 } ); reserve_q.set( { amount: 0, cleared: reserve_q.helpers().now(), } ); reserve_q.where( { ext_id: xfer.ext_id || null, account: reserve_q.backref( xfer_q, 'src' ), currency_id: reserve_q.backref( xfer_q, 'src_currency_id' ), amount: reserve_q.backref( xfer_q, 'src_amount' ), } ); } } else { acct_info = xfer.dst_info; account = xfer.dst_account; balance_field = 'dst_post_balance'; xfer_q.get( [ 'dst', 'dst_amount', 'dst_currency_id' ] ); xfer_q.where( 'xfer_status', this.ST_DONE ); xfer_q.where( 'dst_post_balance IS NULL' ); acct_q.set( 'balance', acct_q.expr( `balance + ${acct_q.backref( xfer_q, 'dst_amount' )}` ) ); acct_q.set( 'updated', dbxfer.helpers().now() ); acct_q.where( 'uuidb64', acct_q.backref( xfer_q, 'dst' ) ); acct_q.where( 'currency_id', acct_q.backref( xfer_q, 'dst_currency_id' ) ); } // double safety acct_q.where( 'uuidb64', account ); acct_q.where( 'currency_id', acct_info.currency_id ); this._recordBalance( dbxfer, xfer, account, balance_field, acct_info.dec_places ); } _recordBalance( dbxfer, xfer, account, target_field, dec_places ) { const helpers = dbxfer.helpers(); const acct_q = dbxfer.select( DB_ACCOUNTS_TABLE, { selected: 1 } ); acct_q.get( [ 'balance', 'reserved' ] ).where( 'uuidb64', account ); if ( target_field ) { const xfer_q = dbxfer.update( DB_XFERS_TABLE, { affected: 1 } ); xfer_q.set( target_field, xfer_q.backref( acct_q, 'balance' ) ); xfer_q.where( 'uuidb64', xfer.id ); } const evtgen = this._ccm.iface( EVTGEN_ALIAS ); const evt_q = dbxfer.insert( evtgen.DB_EVENT_TABLE, { affected: 1 } ) .set( 'type', 'ACCT_BAL' ) .set( 'ts', helpers.now() ); //========= let evt_data; if ( typeof account === 'string' ) { evt_data = [ `{"id":${JSON.stringify( account )},"balance":"`, ]; } else { evt_data = [ '{"id":"', evt_q.backref( account, 'account' ), '","balance":"', ]; } const format_amt = ( backref ) => helpers.cast( helpers.div( backref, AmountTools.place2div( dec_places ) ), `DECIMAL(${AmountTools.MAX_DIGITS},${dec_places})` ); evt_data = evt_data.concat( [ format_amt( evt_q.backref( acct_q, 'balance' ) ), '","reserved":"', format_amt( evt_q.backref( acct_q, 'reserved' ) ), `"}`, ] ); evt_data = helpers.concat( ...evt_data ); evt_data = helpers.cast( evt_data, 'JSON' ); //========= evt_q.set( 'data', evt_data ); } _usePreAuth( as, dbxfer, xfer ) { if ( !xfer.use_preauth ) { return; } const src_info = xfer.src_info; const helpers = dbxfer.helpers(); const db = dbxfer.lface(); const preauth_ext_id = ( xfer.type == 'Bet' ) ? xfer.use_preauth : db.select( DB_XFERS_TABLE ) .get( 'ext_id' ) .where( { uuidb64 : xfer.use_preauth, xfer_status : this.ST_DONE, } ); db.select( DB_RESERVATIONS_TABLE ) .get( [ 'account', 'amount' ] ) .where( { ext_id: preauth_ext_id, currency_id: src_info.currency_id, } ) .executeAssoc( as ); as.add( ( as, rows ) => { if ( xfer.type === 'Bet' ) { const used_amounts = {}; let total_reserved = '0'; let main; for ( let r of rows ) { if ( r.account === src_info.uuidb64 ) { main = r; } if ( AmountTools.isZero( r.amount ) ) { continue; } used_amounts[ r.account ] = AmountTools.fromStorage( r.amount, src_info.dec_places ); total_reserved = AmountTools.add( total_reserved, r.amount, 0 ); dbxfer.update( DB_RESERVATIONS_TABLE, { affected: 1 } ) .set( { amount: 0, cleared: helpers.now(), } ) .where( { ext_id: xfer.ext_id, account: r.account, amount: r.amount, } ); if ( r.account !== src_info.uuidb64 ) { const amt_q = helpers.escape( r.amount ); dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'reserved', helpers.expr( `reserved - ${amt_q}` ) ) .set( 'balance', helpers.expr( `balance - ${amt_q}` ) ) .where( { uuidb64: r.account, 'reserved >=': r.amount, 'balance >=': r.amount, // extra safety enabled: 'Y', // only for Bonus here currency_id: src_info.currency_id, holder: src_info.holder, } ); this._recordBalance( dbxfer, null, r.account, null, src_info.dec_places ); } } assert( main ); let bonus_part = AmountTools.subtract( total_reserved, main.amount, 0 ); dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'reserved', helpers.expr( `reserved - ${helpers.escape( main.amount )}` ) ) .set( 'balance', helpers.expr( `balance + ${helpers.escape( bonus_part )}` ) ) .where( { uuidb64: main.account, 'reserved >=': main.amount, // extra safety currency_id: src_info.currency_id, holder: src_info.holder, } ); this._recordBalance( dbxfer, null, main.account, null, src_info.dec_places ); //--- total_reserved = AmountTools.fromStorage( total_reserved, src_info.dec_places ); bonus_part = AmountTools.fromStorage( bonus_part, src_info.dec_places ); if ( !AmountTools.isEqual( total_reserved, xfer.src_amount ) ) { as.error( 'InternalError', `Bet reservation mismatch ${total_reserved} != ${xfer.src_amount}` ); } xfer.misc_data.used_amounts = used_amounts; xfer.misc_data.bonus_part = bonus_part; xfer.preauth_amount = total_reserved; } else if ( rows.length === 1 ) { const r = rows[0]; if ( r.account !== xfer.src_account ) { as.error( 'InternalError', 'Pre-auth account mismatch' ); } dbxfer.update( DB_RESERVATIONS_TABLE, { affected: 1 } ) .set( { amount: 0, cleared: helpers.now(), } ) .where( { ext_id: preauth_ext_id, account: r.account, amount: r.amount, } ); dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'reserved', dbxfer.expr( `reserved - ${dbxfer.escape( r.amount )}` ) ) .where( { uuidb64: r.account, 'reserved >=': r.amount, // extra safety currency_id: src_info.currency_id, holder: src_info.holder, } ); this._recordBalance( dbxfer, null, r.account, null, src_info.dec_places ); xfer.misc_data.used_preauth = xfer.use_preauth; xfer.preauth_amount = AmountTools.fromStorage( r.amount, src_info.dec_places ); if ( AmountTools.isLessOrEqual( xfer.src_amount, xfer.preauth_amount ) ) { xfer.user_confirm = true; } } else { as.error( 'UnavailablePreAuth' ); } } ); } _preLockAccounts( dbxfer, xfer ) { const accounts = [ xfer.src_account, xfer.dst_account, ]; //--- const in_xfer = xfer.in_xfer; in_xfer && accounts.push( in_xfer.src_account ); //--- const out_xfer = xfer.out_xfer; out_xfer && accounts.push( out_xfer.dst_account ); //--- const fee_xfer = xfer.fee_xfer; fee_xfer && accounts.push( fee_xfer.dst_account ); //--- const extra_fee = xfer.extra_fee; extra_fee && accounts.push( extra_fee.dst_account ); //--- dbxfer.select( DB_ACCOUNTS_TABLE ) .get( 'enabled' ) .where( 'uuidb64 IN', accounts ) .forUpdate(); } _createXfer( as, dbxfer, xfer ) { as.add( ( as ) => { let cancel = ( xfer.status === this.ST_CANCELED ); let decrease_now = false; const misc_data = xfer.misc_data; if ( misc_data.do_check && !xfer.user_confirm && !cancel && !xfer.in_transit && ( xfer.status === this.ST_DONE ) ) { xfer.status = this.ST_WAIT_USER; } if ( cancel ) { // pass all } else if ( xfer.status === this.ST_WAIT_EXT_IN ) { /// pass all } else if ( ( xfer.in_transit || xfer.in_xfer_fee ) && ( xfer.status === this.ST_WAIT_USER || xfer.status === this.ST_WAIT_EXT_OUT ) ) { // pass only on out } else { decrease_now = true; } if ( xfer.out_xfer && ( xfer.status === this.ST_DONE ) ) { xfer.status = this.ST_WAIT_EXT_OUT; } //========================= xfer.created = xfer.updated = moment.utc().format(); const q_now = dbxfer.helpers().date( xfer.created ); // Xfer const xfer_q = dbxfer.insert( DB_XFERS_TABLE, { affected: 1 } ); xfer_q.set( { uuidb64 : xfer.id, src : xfer.src_account, src_currency_id : xfer.src_info.currency_id, src_amount : AmountTools.toStorage( xfer.src_amount, xfer.src_info.dec_places ), dst : xfer.dst_account, dst_currency_id : xfer.dst_info.currency_id, dst_amount : AmountTools.toStorage( xfer.dst_amount, xfer.dst_info.dec_places ), created : q_now, updated : q_now, xfer_type: xfer.type, xfer_status: xfer.status, } ); const xfer_event = { id: xfer.id, src : xfer.src_account,