UNPKG

futoin-xferengine

Version:

Neutral clustered multi-currency transaction engine with limits

802 lines (682 loc) 26.9 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 XferTools = require( './XferTools' ); const AmountTools = require( './AmountTools' ); const UUIDTool = require( './UUIDTool' ); const { DB_ACCOUNTS_TABLE, DB_ACCOUNTS_VIEW, DB_CURRENCY_TABLE, DB_RESERVATIONS_TABLE, DB_ROUNDS_TABLE, DB_ROUND_XFERS_TABLE, DB_XFERS_TABLE, } = require( './main' ); /** * XferTools with focus on Gaming use case */ class GamingTools extends XferTools { constructor( ccm ) { super( ccm, 'Gaming' ); } findAccounts( as, user_ext_id, currency, _info ) { const db = this.db; const q = db.select( DB_ACCOUNTS_VIEW ) .where( { ext_holder_id: user_ext_id, currency: currency, account_enabled: 'Y', } ) .order( 'account_created' ); q.executeAssoc( as ); as.add( ( as, accounts ) => { const bonus = []; let main; for ( let a of accounts ) { if ( a.acct_type === this.ACCT_BONUS ) { bonus.push( a ); } else if ( !main ) { main = a; } else { as.error( 'InternalError', 'More than one currency account' ); } } if ( main ) { as.success( main, bonus ); } else { as.error( 'CurrencyMismatch' ); } } ); } getGameBalance( as, user_ext_id, currency, info ) { const db = this.db; db.select( DB_ACCOUNTS_VIEW ) .where( { ext_holder_id: user_ext_id, currency: currency, account_enabled: 'Y', holder_enabled: 'Y', } ) .executeAssoc( as ); as.add( ( as, accounts ) => { let game_balance = '0'; let main; let do_transit = false; for ( let a of accounts ) { if ( a.acct_type === this.ACCT_BONUS ) { // pass } else if ( !main ) { main = a; } else { as.error( 'InternalError', 'More than one currency account' ); } if ( a.acct_type === this.ACCT_TRANSIT ) { do_transit = true; } else { AmountTools.accountFromStorage( a ); game_balance = AmountTools.add( game_balance, a.available_balance, a.dec_places ); } } if ( !main ) { as.error( 'CurrencyMismatch' ); } else if ( do_transit ) { this._ccm.xferIface( as, 'futoin.xfer.gaming', main.rel_uuidb64 ); as.add( ( as, iface ) => iface.gameBalance( as, user_ext_id, currency, info ) ); as.add( ( as, { balance } ) => { game_balance = AmountTools.add( game_balance, balance, main.dec_places ); as.success( game_balance ); } ); } else { as.success( game_balance ); } } ); } //----------------------- closeBonus( as, xfer ) { const dbxfer = this.db.newXfer(); dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'rel_uuidb64', xfer.dst_account ) .set( 'enabled', 'N' ) .where( 'uuidb64', xfer.src_account ); dbxfer.select( DB_ACCOUNTS_VIEW, { selected: 1, result: true } ) .where( 'uuidb64', xfer.src_account ); dbxfer.executeAssoc( as ); as.add( ( as, res ) => { const info = res[0].rows[0]; AmountTools.accountFromStorage( info ); xfer.amount = info.available_balance; xfer.force = true; if ( !AmountTools.isZero( xfer.amount ) ) { this.processXfer( as, xfer ); } } ); } reserveBet( as, xfer, main, bonus_accounts ) { if ( main.acct_type === this.ACCT_TRANSIT ) { // TODO: support bonus accounts in transit system return; } if ( bonus_accounts.length <= 0 ) { return; } const accounts = [ main, ...bonus_accounts ]; // fail early { let total_balance = '0'; accounts.forEach( ( a ) => { AmountTools.accountFromStorage( a ); total_balance = AmountTools.add( total_balance, a.available_balance, a.dec_places ); } ); if ( AmountTools.isLess( total_balance, xfer.amount ) ) { // Let if fail through default path return; } } const db = this.db; const dbxfer = db.newXfer(); const helpers = db.helpers(); const dec_places = bonus_accounts[0].dec_places; const amt_q = helpers.expr( helpers.escape( AmountTools.toStorage( xfer.amount, dec_places ) ) ); // Make sure there are no previous reservations or xfers dbxfer.select( DB_RESERVATIONS_TABLE, { selected: 0 } ) .where( 'ext_id', xfer.ext_id ) .forUpdate(); dbxfer.select( DB_XFERS_TABLE, { selected: 0 } ) .where( 'ext_id', xfer.ext_id ) .forUpdate(); //--- let prev_q; for ( let a of accounts ) { const sq = dbxfer.select( DB_ACCOUNTS_TABLE, { result: true } ); sq.where( { uuidb64: a.uuidb64, enabled: 'Y', } ); sq.forUpdate(); const leftover = helpers.cast( prev_q ? sq.backref( prev_q, 'leftover' ) : amt_q, `DECIMAL(${AmountTools.MAX_DIGITS},0)` ); const part = helpers.greatest( helpers.least( helpers.expr( '(balance - reserved)' ), leftover ), 0 ); sq.get( 'leftover', `(${leftover} - ${part})` ); sq.get( 'part', helpers.cast( helpers.expr( part ), 'TEXT' ) ); sq.get( 'account', 'uuidb64' ); //--- const uq = dbxfer.update( DB_ACCOUNTS_TABLE ); uq.where( 'uuidb64', a.uuidb64 ); const new_reserved = helpers.expr( `reserved + ${uq.backref( sq, 'part' )}` ); uq.set( 'reserved', new_reserved ); uq.where( 'balance >=', new_reserved ); this._recordBalance( dbxfer, null, a.uuidb64, null, dec_places ); //--- const reserve_q = dbxfer.insert( DB_RESERVATIONS_TABLE ); reserve_q.set( { ext_id: xfer.ext_id, account: a.uuidb64, currency_id: a.currency_id, amount: reserve_q.backref( sq, 'part' ), created: helpers.now(), } ); //--- prev_q = sq; } as.add( ( as ) => { dbxfer.executeAssoc( as ); as.add( ( as, result ) => { as.success( result.map( ( v ) => v.rows[0] ) ); } ); }, ( as, err ) => { // already reserved if ( err === 'XferCondition' ) { db.select( DB_RESERVATIONS_TABLE ) .get( 'part', 'amount' ) .get( 'account' ) .where( 'ext_id', xfer.ext_id ) .executeAssoc( as ); } } ); as.add( ( as, rows ) => { let total = '0'; for ( let r of rows ) { const part = AmountTools.fromStorage( r.part, dec_places ); total = AmountTools.add( total, part, dec_places ); } // Make sure amounts exactly match (e.g. handle repeats) if ( AmountTools.isEqual( total, xfer.amount ) ) { xfer.use_preauth = xfer.ext_id; } else if ( !AmountTools.isZero( total ) ) { // clear reservaton and let it fail through standard path this.clearReservedBet( as, xfer, rows ); } } ); } clearReservedBet( as, xfer, rows=null ) { const db = this.db; if ( !rows ) { db.select( [ DB_RESERVATIONS_TABLE, 'R' ] ) .innerJoin( [ DB_ACCOUNTS_TABLE, 'A' ], 'A.uuidb64 = R.account' ) .innerJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = R.currency_id' ) .get( 'account', 'R.account' ) .get( 'enabled', 'A.enabled' ) .get( 'rel_uuidb64' ) .get( 'dec_places' ) .where( 'R.ext_id', xfer.ext_id ) .executeAssoc( as ); as.add( ( as, r ) => rows = r ); } as.add( ( as ) => { const dbxfer = db.newXfer(); for ( let r of rows ) { // get actual & lock in DB xfer const sq = dbxfer.select( DB_RESERVATIONS_TABLE, { selected: 1 } ); sq.get( 'amount' ); sq.where( { ext_id: xfer.ext_id, account: r.account, } ); sq.forUpdate(); // clear reservation itself const urq = dbxfer.update( DB_RESERVATIONS_TABLE, { affected: 1 } ); urq.set( 'amount', urq.expr( `amount - ${urq.backref( sq, 'amount' )}` ) ); urq.where( { ext_id: xfer.ext_id, account: r.account, 'amount >=': urq.backref( sq, 'amount' ), } ); // release account const uaq = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ); const amt_q = uaq.backref( sq, 'amount' ); uaq.set( 'reserved', uaq.expr( `reserved - ${amt_q}` ) ); this._recordBalance( dbxfer, null, r.account, null, r.dec_places ); if ( r.enabled === 'Y' ) { uaq.where( { uuidb64: r.account, enabled: 'Y', 'reserved >=': amt_q, } ); } else { uaq.set( 'balance', uaq.expr( `balance - ${amt_q}` ) ); uaq.where( { uuidb64: r.account, enabled: 'N', 'reserved >=': amt_q, 'balance >=': amt_q, } ); // Move to passthrough target const dst_uaq = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ); dst_uaq.set( 'balance', dst_uaq.expr( `balance - ${dst_uaq.backref( sq, 'amount' )}` ) ); dst_uaq.where( 'uuidb64', r.rel_uuidb64 ); this._recordBalance( dbxfer, null, r.rel_uuidb64, null, r.dec_places ); } } } ); } _dsitributeBonusWin( as, dbxfer, xfer ) { const db = dbxfer.lface(); db.select( DB_XFERS_TABLE ) .get( 'misc_data' ) .where( { 'ext_id IN': db.select( DB_ROUND_XFERS_TABLE ) .get( 'ext_id' ) .where( 'round_id', xfer.misc_data.round_id ), xfer_type: 'Bet', xfer_status: this.ST_DONE, } ) .executeAssoc( as ); as.add( ( as, rows ) => { // Fail if new bets arrive in the middle of processing //--- dbxfer.select( DB_XFERS_TABLE, { selected: rows.length } ) .get( 'misc_data' ) .where( { 'ext_id IN': db.select( DB_ROUND_XFERS_TABLE ) .get( 'ext_id' ) .where( 'round_id', xfer.misc_data.round_id ), xfer_type: 'Bet', xfer_status: this.ST_DONE, } ); //--- let distrib = false; const dst_info = xfer.dst_info; const dec_places = dst_info.dec_places; const contribs = {}; // Find out bonus contributions //--- for ( let r of rows ) { const used_amounts = JSON.parse( r.misc_data ).used_amounts; if ( used_amounts ) { distrib = true; for ( let a in used_amounts ) { if ( a in contribs ) { contribs[a] = AmountTools.add( contribs[a], used_amounts[a], dec_places ); } else { contribs[a] = used_amounts[a]; } } } } if ( !distrib ) { return; } //--- const helpers = dbxfer.helpers(); distrib = AmountTools.distributeWin( contribs, xfer.dst_amount, dec_places ); { const amt = AmountTools.toStorage( xfer.dst_amount, dec_places ); dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'balance', helpers.expr( `balance - ${helpers.escape( amt )}` ) ) .where( { uuidb64: xfer.dst_account, // extra safety currency_id: dst_info.currency_id, holder: dst_info.holder, } ); this._recordBalance( dbxfer, null, xfer.dst_account, null, dec_places ); xfer.misc_data.win_distrib = distrib; this._updateMiscData( dbxfer, xfer ); } for ( let account in distrib ) { const amt = AmountTools.toStorage( distrib[account], dec_places ); const account_q = this._bonusAccountTarget( dbxfer, account ); const upd_q = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'balance', helpers.expr( `balance + ${helpers.escape( amt )}` ) ); upd_q.where( { uuidb64: upd_q.backref( account_q, 'account' ), // extra safety enabled: 'Y', // only for Bonus here currency_id: dst_info.currency_id, } ); this._recordBalance( dbxfer, null, account_q, null, dec_places ); } } ); } _cancelBonusContrib( dbxfer, xfer ) { const used_amounts = xfer.misc_data.used_amounts; if ( ( xfer.type !== 'Bet' ) || !used_amounts || ( xfer.status === this.ST_CANCELED ) ) { return; } const src_info = xfer.src_info; const dec_places = src_info.dec_places; const helpers = dbxfer.helpers(); let total_contrib = '0'; for ( let account in used_amounts ) { if ( account === xfer.src_account ) { continue; } const amount = AmountTools.toStorage( used_amounts[ account ], dec_places ); total_contrib = AmountTools.add( total_contrib, amount, 0 ); const account_q = this._bonusAccountTarget( dbxfer, account ); const upd_q = dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'balance', helpers.expr( `balance + ${helpers.escape( amount )}` ) ); upd_q.where( { uuidb64: upd_q.backref( account_q, 'account' ), // extra safety enabled: 'Y', // only for Bonus here currency_id: src_info.currency_id, } ); this._recordBalance( dbxfer, null, account_q, null, dec_places ); } if ( !AmountTools.isZero( total_contrib ) ) { dbxfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( 'balance', helpers.expr( `balance - ${helpers.escape( total_contrib )}` ) ) .where( { uuidb64: xfer.src_account, // extra safety currency_id: src_info.currency_id, holder: src_info.holder, } ); this._recordBalance( dbxfer, null, xfer.src_account, null, dec_places ); } } _bonusAccountTarget( dbxfer, account ) { const q = dbxfer.select( DB_ACCOUNTS_TABLE, { selected: 1 } ) .get( 'account', dbxfer.expr( // TODO: move to DB helpers `CASE WHEN enabled=${dbxfer.escape( 'Y' )} THEN uuidb64 ELSE rel_uuidb64 END` ) ) .where( 'uuidb64', account ); return q; } //----------------------- _dbGameBalance( dbxfer, xfer ) { // NOTE: always get actual balance let acct_info; switch ( xfer.type ) { case 'Bet': acct_info = xfer.src_info; if ( !xfer.misc_data.bonus_part ) { xfer.misc_data.bonus_part = AmountTools.fromStorage( '0', acct_info.dec_places ); } break; case 'Win': acct_info = xfer.dst_info; break; } if ( acct_info ) { dbxfer.select( DB_ACCOUNTS_VIEW, { result: true } ) .get( 'game_balance', 'COALESCE(SUM(balance + COALESCE(overdraft, 0) - reserved), 0)' ) .where( { holder: acct_info.holder, holder_enabled: 'Y', account_enabled: 'Y', } ); xfer.game_balance_dec_places = acct_info.dec_places; } } _handleGameBalance( as, xfer, result ) { if ( xfer.game_balance_dec_places ) { xfer.game_balance = AmountTools.fromStorage( result.rows[0]['game_balance'], xfer.game_balance_dec_places ); } } //----------------------- genRoundID( as, xfer, ext_round_id ) { const db = this.db; const ext_info = xfer.misc_data.info; ext_info.orig_round_id = ext_info.orig_round_id || ext_round_id; // TODO: caching as.add( ( as ) => { const dbxfer = db.newXfer(); const round_id = xfer.misc_data.round_id = UUIDTool.genB64(); dbxfer.insert( DB_ROUNDS_TABLE, { affected: 1 } ) .set( { round_id, ext_round_id } ); UUIDTool.addXfer( dbxfer, round_id ); // minimize locking of global UUID table dbxfer.execute( as ); }, ( as, err ) => { if ( err === 'Duplicate' ) { db.select( DB_ROUNDS_TABLE ) .get( 'round_id' ) .where( { ext_round_id } ) .execute( as ); as.add( ( as, { rows } ) => { xfer.misc_data.round_id = rows[0][0]; } ); } } ); } _recordRoundXfer( as, dbxfer, xfer ) { switch ( xfer.type ) { case 'Bet': case 'Win': dbxfer.insert( DB_ROUND_XFERS_TABLE, { affected: 1 } ) .set( { round_id : xfer.misc_data.round_id, ext_id : xfer.ext_id, } ); break; } } _checkNoRoundWin( dbxfer, xfer ) { const round_id = xfer.misc_data.round_id; if ( !round_id || xfer.repeat || ( xfer.type === this.ST_CANCELED ) ) { // allow phantom cancel and repeats return; } dbxfer.select( DB_XFERS_TABLE, { selected: 0 } ) .where( 'ext_id IN', dbxfer.lface() .select( DB_ROUND_XFERS_TABLE ) .get( 'ext_id' ) .where( 'round_id', xfer.misc_data.round_id ) ) .where( 'xfer_type', 'Win' ); } //----------------------- _domainDbStep( as, dbxfer, xfer ) { if ( !xfer.repeat ) { this._recordRoundXfer( as, dbxfer, xfer ); switch ( xfer.type ) { case 'Bet': this._checkNoRoundWin( dbxfer, xfer ); break; case 'Win': this._dsitributeBonusWin( as, dbxfer, xfer ); break; } } // Must be the last after all sub-steps as.add( ( as ) => this._dbGameBalance( dbxfer, xfer ) ); } _domainDbResult( as, xfer, result ) { this._handleGameBalance( as, xfer, result[0] ); } //----------------------- _domainDbCancelStep( as, dbxfer, xfer ) { this._cancelBonusContrib( dbxfer, xfer ); this._dbGameBalance( dbxfer, xfer ); this._checkNoRoundWin( dbxfer, xfer ); } _domainDbCancelResult( as, xfer, result ) { this._handleGameBalance( as, xfer, result[0] ); } //----------------------- _domainExtIn( as, xfer ) { if ( xfer.type !== 'Bet' ) { return super._domainExtIn( as, xfer ); } const in_xfer = xfer.in_xfer; const acct_info = in_xfer.dst_info; this._ccm.xferIface( as, 'futoin.xfer.gaming', in_xfer.src_account ); as.add( ( as, iface ) => iface.call( as, 'bet', { user : acct_info.ext_holder_id, rel_account: in_xfer.src_info.ext_acct_id, currency: in_xfer.currency, amount: in_xfer.amount, ext_id: xfer.id, round_id: xfer.misc_data.round_id, ext_info: xfer.misc_data.info, orig_ts: xfer.misc_data.orig_ts, } ) ); as.add( ( as, { balance, bonus_part } ) => { // add local amounts xfer.game_balance = AmountTools.add( xfer.game_balance, balance, acct_info.dec_places ); xfer.game_balance_ext = balance; if ( !AmountTools.isZero( bonus_part ) ) { xfer.misc_data.bonus_part = AmountTools.add( xfer.misc_data.bonus_part, bonus_part, acct_info.dec_places ); xfer.update_misc_data = true; } } ); } _domainCancelExtIn( as, xfer ) { if ( xfer.type !== 'Bet' ) { return super._domainCancelExtIn( as, xfer ); } const in_xfer = xfer.in_xfer; const acct_info = in_xfer.dst_info; this._ccm.xferIface( as, 'futoin.xfer.gaming', in_xfer.src_account ); as.add( ( as, iface ) => iface.call( as, 'cancelBet', { user : acct_info.ext_holder_id, rel_account: in_xfer.src_info.ext_acct_id, currency: in_xfer.currency, amount: in_xfer.amount, ext_id: xfer.id, round_id: xfer.misc_data.round_id, ext_info: xfer.misc_data.info, orig_ts: xfer.misc_data.orig_ts, reason: xfer.misc_data.cancel_reason, } ) ); as.add( ( as, { balance } ) => { // add local amounts xfer.game_balance = AmountTools.add( xfer.game_balance, balance, acct_info.dec_places ); xfer.game_balance_ext = balance; } ); } //----------------------- _domainExtOut( as, xfer ) { if ( xfer.type !== 'Win' ) { return super._domainExtOut( as, xfer ); } const out_xfer = xfer.out_xfer; const acct_info = out_xfer.src_info; this._ccm.xferIface( as, 'futoin.xfer.gaming', out_xfer.dst_account ); as.add( ( as, iface ) => iface.call( as, 'win', { user : acct_info.ext_holder_id, rel_account: out_xfer.dst_info.ext_acct_id, currency: out_xfer.currency, amount: out_xfer.amount, ext_id: xfer.id, round_id: xfer.misc_data.round_id, ext_info: xfer.misc_data.info, orig_ts: xfer.misc_data.orig_ts, } ) ); as.add( ( as, { balance } ) => { // add local amounts xfer.game_balance = AmountTools.add( xfer.game_balance, balance, acct_info.dec_places ); xfer.game_balance_ext = balance; } ); } _domainCancelExtOut( as, xfer ) { if ( xfer.type !== 'Win' ) { return super._domainCancelExtOut( as, xfer ); } as.error( 'InternalError', 'Win cancellation is not allowed' ); } //----------------------- _domainPostExternal( as, xfer ) { if ( ( xfer.type === 'Bet' || xfer.type === 'Win' ) && !xfer.game_balance_ext ) { const acct_info = ( xfer.type === 'Bet' ) ? xfer.src_info : xfer.dst_info; if ( acct_info.acct_type === this.ACCT_TRANSIT ) { this._ccm.xferIface( as, 'futoin.xfer.gaming', acct_info.rel_uuidb64 ); as.add( ( as, iface ) => { iface.gameBalance( as, acct_info.ext_holder_id, xfer.currency, xfer.misc_data.info ); } ); as.add( ( as, { balance } ) => { xfer.game_balance = AmountTools.add( xfer.game_balance, balance, xfer.game_balance_dec_places ); } ); } } } } module.exports = GamingTools;