UNPKG

futoin-xferengine

Version:

Neutral clustered multi-currency transaction engine with limits

719 lines (617 loc) 24.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 BaseService = require( './BaseService' ); const moment = require( 'moment' ); const AccountsFace = require( './AccountsFace' ); const UUIDTool = require( './UUIDTool' ); const AmountTools = require( './AmountTools' ); const { DB_ACCOUNT_HOLDERS_TABLE, DB_ACCOUNTS_VIEW, DB_ACCOUNTS_TABLE, DB_CURRENCY_TABLE, DB_DOMAIN_LIMITS_TABLE, DB_LIMIT_GROUPS_TABLE, EVTGEN_ALIAS, limitStatsTable, } = require( './main' ); const SYM_GET_AH_ID = Symbol( 'getAccountHolder' ); const SYM_GET_AH_EXTID = Symbol( 'getAccountHolderExt' ); const SYM_GET_ACCOUNT = Symbol( 'getAccount' ); const SYM_GET_ACCOUNT_EXT = Symbol( 'getAccountExt' ); const SYM_LIST_ACCOUNTS = Symbol( 'listAccounts' ); const SYM_GET_LIMIT_STATS = Symbol( 'getLimitStats1' ); /** * Accounts Service */ class AccountsService extends BaseService { static get IFACE_IMPL() { return AccountsFace; } //============= addAccountHolder( as, reqinfo ) { as.add( ( as ) => { const p = reqinfo.params(); const ccm = reqinfo.executor().ccm(); const db = ccm.db( 'xfer' ); const evtgen = ccm.iface( EVTGEN_ALIAS ); const xfer = db.newXfer(); const now = xfer.helpers().now(); const uuidb64 = UUIDTool.genXfer( xfer ); const sq = xfer.select( DB_LIMIT_GROUPS_TABLE, { selected: 1, result: true } ) .get( 'id' ) .where( 'group_name', p.group ); const iq = xfer.insert( DB_ACCOUNT_HOLDERS_TABLE ); iq.set( { uuidb64, ext_id: p.ext_id, group_id: iq.backref( sq, 'id' ), enabled: p.enabled ? 'Y' : 'N', kyc: p.kyc ? 'Y' : 'N', data: JSON.stringify( p.data ), internal: JSON.stringify( p.internal ), created: now, updated: now, } ); evtgen.addXferEvent( xfer, 'AH_NEW', Object.assign( { id: uuidb64 }, p ) ); xfer.execute( as ); as.add( ( as ) => reqinfo.result( uuidb64 ) ); }, ( as, err ) => { if ( err === 'XferCondition' ) { as.error( 'UnknownLimitGroup' ); } if ( err === 'Duplicate' ) { as.error( 'DuplicateExtID' ); } } ); } updateAccountHolder( as, reqinfo ) { as.add( ( as ) => { const p = reqinfo.params(); const ccm = reqinfo.executor().ccm(); const db = ccm.db( 'xfer' ); const evtgen = ccm.iface( EVTGEN_ALIAS ); const xfer = db.newXfer(); let sq; if ( p.group !== null ) { sq = xfer.select( DB_LIMIT_GROUPS_TABLE, { selected: 1 } ) .get( 'id' ) .where( 'group_name', p.group ); } const iq = xfer.update( DB_ACCOUNT_HOLDERS_TABLE, { affected: 1 } ); const toset = {}; if ( p.group !== null ) { toset.group_id = iq.backref( sq, 'id' ); } if ( p.enabled !== null ) { toset.enabled = p.enabled ? 'Y' : 'N'; if ( !p.enabled ) { evtgen.addXferEvent( xfer, 'AH_BLOCK', { id: p.id } ); } } if ( p.kyc !== null ) { toset.kyc = p.kyc ? 'Y' : 'N'; } if ( p.data !== null ) { toset.data = JSON.stringify( p.data ); } if ( p.internal !== null ) { toset.internal = JSON.stringify( p.internal ); } iq.set( toset ).where( 'uuidb64', p.id ); iq.set( 'updated', xfer.helpers().now() ); evtgen.addXferEvent( xfer, 'AH_UPD', p ); xfer.execute( as ); as.add( ( as ) => reqinfo.result( true ) ); }, ( as, err ) => { if ( err === 'XferCondition' ) { if ( as.state.error_info.startsWith( 'Affected' ) ) { as.error( 'UnknownHolderID' ); } else { as.error( 'UnknownLimitGroup' ); } } } ); } _getAccountHolderCommon( as, reqinfo, sym, field, value ) { const db = reqinfo.executor().ccm().db( 'xfer' ); db .getPrepared( sym, ( db ) => { const qb = db.select( [ DB_ACCOUNT_HOLDERS_TABLE, 'A' ] ) .innerJoin( DB_LIMIT_GROUPS_TABLE, `${DB_LIMIT_GROUPS_TABLE}.id = group_id` ) .get( [ 'A.*', 'group_name' ] ); qb.where( field, qb.param( 'value' ) ); return qb.prepare(); } ) .executeAssoc( as, { value } ); as.add( ( as, rows ) => { if ( rows.length !== 1 ) { as.error( 'UnknownHolderID' ); } const r = rows[0]; reqinfo.result( { id: r.uuidb64, ext_id: r.ext_id, group: r.group_name, enabled: r.enabled === 'Y', kyc: r.kyc === 'Y', data: JSON.parse( r.data ), internal: JSON.parse( r.internal ), created: moment.utc( r.created ).format(), updated: moment.utc( r.updated ).format(), } ); } ); } getAccountHolder( as, reqinfo ) { this._getAccountHolderCommon( as, reqinfo, SYM_GET_AH_ID, 'uuidb64', reqinfo.params().id ); } getAccountHolderExt( as, reqinfo ) { this._getAccountHolderCommon( as, reqinfo, SYM_GET_AH_EXTID, 'ext_id', reqinfo.params().ext_id ); } mergeAccountHolders( as, _reqinfo ) { // TODO: as.error( 'NotImplemented' ); } //============= addAccount( as, reqinfo ) { as.add( ( as ) => { const p = reqinfo.params(); const ccm = reqinfo.executor().ccm(); const db = ccm.db( 'xfer' ); const evtgen = ccm.iface( EVTGEN_ALIAS ); //--- db.select( DB_ACCOUNT_HOLDERS_TABLE ) .get( 'uuidb64' ) .where( 'uuidb64', p.holder ) .execute( as ); as.add( ( as, { rows } ) => { if ( rows.length !== 1 ) { as.error( 'UnknownHolderID' ); } } ); //--- const xfer = db.newXfer(); const now = xfer.helpers().now(); const uuidb64 = UUIDTool.genXfer( xfer ); const cq = xfer.select( DB_CURRENCY_TABLE, { selected: 1 } ) .get( 'id' ) .where( 'code', p.currency ); const iq = xfer.insert( DB_ACCOUNTS_TABLE, { affected: 1 } ); iq.set( { uuidb64, holder: p.holder, currency_id: iq.backref( cq, 'id' ), created: now, updated: now, balance: '0', reserved: '0', enabled: p.enabled ? 'Y' : 'N', acct_type: p.type, acct_alias: p.alias, rel_uuidb64: p.rel_id, ext_acct_id: p.ext_id, } ); evtgen.addXferEvent( xfer, 'ACCT_NEW', Object.assign( { id: uuidb64 }, p ) ); xfer.execute( as ); as.add( ( as ) => reqinfo.result( uuidb64 ) ); }, ( as, err ) => { if ( err === 'XferCondition' ) { as.error( 'UnknownCurrency' ); } } ); } updateAccount( as, reqinfo ) { as.add( ( as ) => { const p = reqinfo.params(); const ccm = reqinfo.executor().ccm(); const db = ccm.db( 'xfer' ); const evtgen = ccm.iface( EVTGEN_ALIAS ); const xfer = db.newXfer(); const q = xfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ); q.set( 'updated', q.helpers().now() ); q.where( 'uuidb64', p.id ); if ( p.alias !== null ) { q.set( 'acct_alias', p.alias ); } if ( p.enabled !== null ) { q.set( 'enabled', p.enabled ? 'Y' : 'N' ); if ( !p.enabled ) { evtgen.addXferEvent( xfer, 'ACCT_CLOSE', { id: p.id } ); } } evtgen.addXferEvent( xfer, 'ACCT_UPD', p ); xfer.execute( as ); reqinfo.result( true ); }, ( as, err ) => { if ( err === 'XferCondition' ) { as.error( 'UnknownAccountID' ); } } ); } setOverdraft( as, reqinfo ) { const p = reqinfo.params(); const ccm = reqinfo.executor().ccm(); const db = ccm.db( 'xfer' ); const evtgen = ccm.iface( EVTGEN_ALIAS ); db.select( DB_ACCOUNTS_VIEW ) .where( 'uuidb64', p.id ) .executeAssoc( as ); as.add( ( as, rows ) => { if ( rows.length !== 1 ) { as.error( 'UnknownAccountID' ); } const acct = rows[0]; if ( acct.currency !== p.currency ) { as.error( 'CurrencyMismatch' ); } const overdraft = AmountTools.toStorage( p.overdraft, acct.dec_places ); const xfer = db.newXfer(); xfer.update( DB_ACCOUNTS_TABLE, { affected: 1 } ) .set( { overdraft } ) .where( { uuidb64 : p.id, currency_id : acct.currency_id, } ); evtgen.addXferEvent( xfer, 'ACCT_UPD', p ); xfer.execute( as ); as.add( ( as ) => reqinfo.result( true ) ); } ); } _accountInfo( raw ) { return { id: raw.uuidb64, holder: raw.holder, type: raw.acct_type, currency: raw.code, alias: raw.acct_alias, enabled: ( raw.enabled === 'Y' ), ext_id: raw.ext_acct_id, rel_id: raw.rel_uuidb64, balance: AmountTools.fromStorage( raw.balance, raw.dec_places ), reserved: AmountTools.fromStorage( raw.reserved, raw.dec_places ), overdraft: AmountTools.fromStorage( raw.overdraft || '0', raw.dec_places ), created: moment.utc( raw.created ).format(), updated: moment.utc( raw.updated ).format(), }; } getAccount( as, reqinfo ) { const db = reqinfo.executor().ccm().db( 'xfer' ); db .getPrepared( SYM_GET_ACCOUNT, ( db ) => { const qb = db.select( [ DB_ACCOUNTS_TABLE, 'A' ] ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = A.currency_id' ) .get( [ 'A.*', 'C.code', 'C.dec_places' ] ); qb.where( 'A.uuidb64', qb.param( 'id' ) ); return qb.prepare(); } ) .executeAssoc( as, reqinfo.params() ); as.add( ( as, rows ) => { if ( rows.length !== 1 ) { as.error( 'UnknownAccountID' ); } reqinfo.result( this._accountInfo( rows[0] ) ); } ); } getAccountExt( as, reqinfo ) { const db = reqinfo.executor().ccm().db( 'xfer' ); db .getPrepared( SYM_GET_ACCOUNT_EXT, ( db ) => { const qb = db.select( [ DB_ACCOUNTS_TABLE, 'A' ] ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = A.currency_id' ) .get( [ 'A.*', 'C.code', 'C.dec_places' ] ); qb.where( 'A.holder', qb.param( 'holder' ) ); qb.where( 'A.ext_acct_id', qb.param( 'ext_id' ) ); return qb.prepare(); } ) .executeAssoc( as, reqinfo.params() ); as.add( ( as, rows ) => { if ( rows.length !== 1 ) { as.error( 'UnknownAccountID' ); } reqinfo.result( this._accountInfo( rows[0] ) ); } ); } listAccounts( as, reqinfo ) { as.add( ( as ) => { const db = reqinfo.executor().ccm().db( 'xfer' ); db .getPrepared( SYM_LIST_ACCOUNTS, ( db ) => { const xfer = db.newXfer(); const ph_holder = xfer.param( 'holder' ); xfer.select( DB_ACCOUNT_HOLDERS_TABLE, { selected: 1 } ) .get( 'uuidb64' ) .where( 'uuidb64', ph_holder ); xfer.select( [ DB_ACCOUNTS_TABLE, 'A' ], { result: true } ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = A.currency_id' ) .get( [ 'A.*', 'C.code', 'C.dec_places' ] ) .where( 'holder', ph_holder ); return xfer.prepare(); } ) .executeAssoc( as, reqinfo.params() ); as.add( ( as, results ) => { const res = results[0].rows.map( v => this._accountInfo( v ) ); reqinfo.result( res ); } ); }, ( as, err ) => { if ( err === 'XferCondition' ) { as.error( 'UnknownHolderID' ); } } ); } convertAccount( as, _reqinfo ) { as.error( 'NotImplemented' ); // evtgen.addXferEvent( xfer, 'ACCT_CONV', p ); } //============= _limitStats( row ) { const stats = {}; for ( let k in row ) { const v = row[k]; if ( k.endsWith( '_cnt' ) ) { stats[k] = v; } else if ( AmountTools.isAmountField( k ) ) { stats[k] = AmountTools.fromStorage( v, row.dec_places ); } } return { currency: row.code, stats, }; } _today() { // TODO: system timezone return moment().utc().format( 'YYYY-MM-DD' ); } getLimitStats( as, reqinfo ) { const p = reqinfo.params(); const holder = p.holder; const domain = p.domain; const db = reqinfo.executor().ccm().db( 'xfer' ); const qb = db.queryBuilder(); const table = limitStatsTable( domain ); const today = this._today(); as.add( ( as ) => { db .getPrepared( SYM_GET_LIMIT_STATS, ( db ) => { const xfer = db.newXfer(); const ph_holder = xfer.param( 'holder' ); xfer.select( DB_ACCOUNT_HOLDERS_TABLE, { selected: 1 } ) .get( 'uuidb64' ) .where( 'uuidb64', ph_holder ); xfer.select( [ xfer.param( 'table' ), 'S' ], { result: true } ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = S.currency_id' ) .get( [ 'S.*', 'C.code', 'C.dec_places' ] ) .where( 'holder', ph_holder ); return xfer.prepare(); } ) .executeAssoc( as, { holder, table : qb.expr( table ), } ); }, ( as, err ) => { if ( err === 'XferCondition' ) { as.error( 'UnknownHolderID' ); } } ); as.add( ( as, res ) => { const row = res[0].rows[0]; if ( row && ( moment.utc( row.stats_date ).isSameOrAfter( today ) ) ) { reqinfo.result( this._limitStats( row ) ); return; } if ( !row ) { const xfer = db.newXfer(); //--- const sel_currency_q = xfer.select( [ DB_ACCOUNT_HOLDERS_TABLE, 'A' ], { selected: 1 } ) .innerJoin( [ DB_DOMAIN_LIMITS_TABLE, 'L' ], 'L.lim_id = A.group_id' ) .get( 'L.currency_id' ) .where( { 'A.uuidb64': holder, 'L.lim_domain' : domain, } ); //--- const insert_q = xfer.insert( table ); insert_q.set( { holder, currency_id: insert_q.backref( sel_currency_q, 'currency_id' ), stats_date: today, } ); //--- xfer.select( [ table, 'S' ], { selected: 1, result: true } ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = S.currency_id' ) .get( [ 'S.*', 'C.code', 'C.dec_places' ] ) .where( 'holder', holder ); //--- xfer.executeAssoc( as ); as.add( ( as, res ) => { // For manual testing of race condition //as.error('Duplicate'); reqinfo.result( this._limitStats( res[0].rows[0] ) ); } ); } else { as.success( 'Update', row ); } }, ( as, err ) => { if ( err === 'Duplicate' ) { as.success( 'Select' ); } if ( err === 'XferCondition' ) { as.error( 'InternalError', `Missing ${domain} limits config for ${holder}` ); } } ); as.add( ( as, action, row ) => { if ( action === 'Select' ) { db.select( [ table, 'S' ] ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = S.currency_id' ) .get( [ 'S.*', 'C.code', 'C.dec_places' ] ) .where( 'holder', holder ) .executeAssoc( as ); as.add( ( as, rows ) => { reqinfo.result( this._limitStats( rows[0] ) ); } ); } else if ( action == 'Update' ) { const stats_date = moment.utc( row.stats_date ); const week_start = moment.utc( today ).startOf( 'week' ); const month_start = moment.utc( today ).startOf( 'month' ); // --- const stats = { stats_date: today }; let full_reset = true; for ( let k in row ) { if ( k.indexOf( '_daily_' ) > 0 ) { stats[k] = '0'; } } if ( stats_date.isBefore( week_start ) ) { for ( let k in row ) { if ( k.indexOf( '_weekly_' ) > 0 ) { stats[k] = '0'; } } } else { full_reset = false; } if ( stats_date.isBefore( month_start ) ) { for ( let k in row ) { if ( k.indexOf( '_monthly_' ) > 0 ) { stats[k] = '0'; } } } else { full_reset = false; } // --- as.add( ( as ) => { // TODO: update currency for stats on mismatch const sel_currency_id = db .select( [ DB_ACCOUNT_HOLDERS_TABLE, 'A' ] ) .innerJoin( [ DB_DOMAIN_LIMITS_TABLE, 'L' ], 'L.lim_id = A.group_id' ) .get( 'L.currency_id' ) .where( { 'A.uuidb64': holder, 'L.lim_domain' : domain, } ); const xfer = db.newXfer(); const uq = xfer.update( table, { affected: 1 } ) .set( stats ) .where( { holder, stats_date: row.stats_date, } ); if ( full_reset ) { uq.set( 'currency_id', sel_currency_id ); } else { uq.where( 'currency_id', sel_currency_id ); } xfer.select( [ table, 'S' ], { selected: 1, result: true } ) .leftJoin( [ DB_CURRENCY_TABLE, 'C' ], 'C.id = S.currency_id' ) .get( [ 'S.*', 'C.code', 'C.dec_places' ] ) .where( 'holder', holder ); xfer.executeAssoc( as ); as.add( ( as, res ) => { reqinfo.result( this._limitStats( res[0].rows[0] ) ); } ); }, ( as, err ) => { if ( err === 'XferCondition' ) { if ( as.state[ SYM_GET_LIMIT_STATS ] ) { as.error( 'InternalError', 'Stats reset failure (currency mismatch?)' ); } else { as.state[ SYM_GET_LIMIT_STATS ] = true; as.success( true ); } } } ); as.add( ( as, retry ) => { if ( retry ) { this.getLimitStats( as, reqinfo ); } } ); } } ); } /** * Register futoin.xfer.accounts interface with Executor * * @function AccountsService.register * @param {AsyncSteps} as - steps interface * @param {Executor} executor - executor instance * @param {object} options - implementation defined options * @returns {AccountsService} instance */ } module.exports = AccountsService;