transaction.js

/*!
 * A Node.JS library for the M-Pesa Mozambique API
 *
 * @author Ivan Ruby <https://ivanruby.com>
 * @license MIT
 */

var axios = require('axios')
var NodeRSA = require('node-rsa')

/**
 * Transaction module - Interacts with the M-Pesa API by exposing c2b, query and reverse methods.
 * Throws errors if any of the methods receives incomplete or invalid parameters, including the class constructor
 *
 * @module Transaction
 * @param {object} options
 * @param {string} [options.api_host=api.sandbox.vm.co.mz] - Hostname for the API
 * @param {string} options.api_key=empty-string - Used for creating authorize trasactions on the API
 * @param {string} options.initiator_identifier=empty-string - Provided by Vodacom MZ
 * @param {string} options.origin=empty-string - Used for identifying hostname which is sending transaction requests
 * @param {string} options.public_key=empty-string - Public Key for the M-Pesa API. Used for generating Authorization bearer tokens
 * @param {string} options.security_credential=empty-string - Provided by Vodacom MZ
 * @param {number} options.service_provider_code=empty-string - Provided by Vodacom MZ
 * @throws 'Missing or invalid configuration parameters' Error if options object is incomplete or invalid
 * @example
 * Transaction = require('mpesa-mz-nodejs-lib')
 *  tx = new Transaction(options)
 *
 * @return {Class} Transaction
 */
module.exports = function (options) {
  /** Public key - Required by the M-Pesa API
  * @member _public_key
  * @type {string}
  */
  this._public_key = options.public_key || '',

  /** API Host - Required by the M-Pesa API
	 * @member _api_host
	 * @type {string}
	 */
  this._api_host = options.api_host || 'api.sandbox.vm.co.mz',

  /** API key - Required by the M-Pesa API
	 * @member _api_key
	 * @type {string}
	 */
  this._api_key = options.api_key || '',

  /** Origin - Required by the M-Pesa API
	 * @member _origin
	 * @type {string}
	 */
  this._origin = options.origin || '',

  /** Service Provider Code - Required by the M-Pesa API
	 * @member _service_provider_code
	 * @type {number}
	 */
  this._service_provider_code = options.service_provider_code || '',

  /** Initiator Identifier - Required by the M-Pesa API
	 * @member _initiator_identifier
	 * @type {string}
	 */
  this._initiator_identifier = options.initiator_identifier || '',

  /** Security Credential - Required by the M-Pesa API
	 * @member _security_credential
	 * @type {string}
	 */
  this._security_credential = options.security_credential || '',

  /**
   * MSISDN Validation
   * @member _validMSISDN - Holds a validated phone number
   */
  this._validMSISDN

  /**
   * Validates a customer's MSISDN (Phone number)
   *
   * @member _isValidMSISDN
   * @function
   * @param {string} msisdn
   * @return {boolean} isValid
   */
  this._isValidMSISDN = function (msisdn) {
    this._validMSISDN = ''
    isValid = false

    // Is it a number?
    if (typeof parseInt(msisdn) === 'number') {
      // Is the length 12 and starts with 258?
      if (msisdn.length === 12 && msisdn.substring(0, 3) === '258') {
        buffer = msisdn.substring(3, 5)
        // Is it an 84 or 85 number?
        if (buffer === '84' || buffer === '85') {
          this._validMSISDN = msisdn
          isValid = true
        }
        // Otherwise, is the length 9?
      } else if (msisdn.length == 9) {
        buffer = msisdn.substring(0, 2)
        // Is it an 84 or 85 number?
        if (buffer === '84' || buffer === '85') {
          this._validMSISDN = '258' + msisdn
          isValid = true
        }
      }
    }

    return isValid
  }

  this._validateAmount = function (amount) {
    return (!amount || amount === '' || isNaN(parseFloat(amount)) || parseFloat(amount) <= 0)
  }

  /**
   * Validation buffer
   * @member validation_errors - Hold array of errors after error validation
   */
  this.validation_errors

  /**
   * Validates all configuration parameters
   *
   * @member _isValidated
   * @function
   * @param {string} type
   * @param {object} data
   * @return {boolean}
   */
  this._isValidated = function (type, data) {
    this.validation_errors = []

    switch (type) {
      case 'config':
        if (!this._api_host || this._api_host === '') { this.validation_errors.push(' API Host') }

        if (!this._api_key || this._api_key === '') { this.validation_errors.push(' API Key') }

        if (!this._initiator_identifier || this._initiator_identifier === '') { this.validation_errors.push(' Initiator Identifier') }

        if (!this._origin || this._origin === '') { this.validation_errors.push(' Origin') }

        if (!this._public_key || this._public_key === '') { this.validation_errors.push(' Public key') }

        if (!this._security_credential || this._security_credential === '') { this.validation_errors.push(' Security credentials') }

        if (!this._service_provider_code || this._service_provider_code === '') { this.validation_errors.push(' Service provider code ') }
        break
      case 'c2b':
        if (this._validateAmount(data.amount)) { this.validation_errors.push(' C2B Amount') }

        if (!data.msisdn || data.msisdn === '' || !this._isValidMSISDN(data.msisdn)) { this.validation_errors.push(' C2B MSISDN') }

        if (!data.reference || data.reference === '') { this.validation_errors.push(' C2B Reference') }

        if (!data.third_party_reference || data.third_party_reference === '') { this.validation_errors.push(' C2B 3rd-party Reference') }

        break
      case 'query':
        if (!data.query_reference || data.query_reference === '') { this.validation_errors.push(' Query Reference') }

        if (!data.third_party_reference || data.third_party_reference === '') { this.validation_errors.push(' Query 3rd-party Reference') }

        break
      case 'reversal':
        if (this._validateAmount(data.amount)) { this.validation_errors.push(' Reversal Amount') }

        if (!data.transaction_id || data.transaction_id === '') { this.validation_errors.push(' Reversal Transaction ID') }

        if (!data.third_party_reference || data.third_party_reference === '') { this.validation_errors.push(' Reversal 3rd-party Reference') }
    }

    if (this.validation_errors.length > 0) { return false }

    return true
  }

  /**
   * Generates a Bearer Token
   * @member _getBearerToken
   * @function
   * @throws 'Missing or invalid configuration parameters' Error if _public_key or _api_key are missing or invalid from object instantiation
   *
   * @return {string} bearer_token
   */
  this._getBearerToken = function () {
    if (this._isValidated('config', {})) {
      // Structuring certificate string
      certificate =
				'-----BEGIN PUBLIC KEY-----\n' +
				this._public_key +
				'\n-----END PUBLIC KEY-----'

      // Create NodeRSA object with public from formatted certificate
      public_key = new NodeRSA()
      public_key.setOptions({ encryptionScheme: 'pkcs1' })
      public_key.importKey(Buffer.from(certificate), 'public')

      // Encryt API key (data) using public key
      token = public_key.encrypt(Buffer.from(this._api_key))

      // Return formatted string, Bearer token in base64 format
      return 'Bearer ' + Buffer.from(token).toString('base64')
    } else {
      throw Error('Missing or invalid configuration parameters:' + this.validation_errors.toString())
    }
  }

  /**
   * Holds the request headers for each API request
   * @member _request_headers
   * @function
   * @type {object}
   * @param {string} _origin - origin value from initialization
   * @param {string} _public_key - public_key value from initialization
   * @param {string} _api_key - api_key value from initialization
   * @throws 'Missing or invalid configuration parameters' Error if _api_key, _origin or _public_key are missing or invalid from object instantiation
   */
  this._request_headers = {}

  this._requestAsPromiseFrom = function(request){
    return new Promise(function (resolve, reject) {
      axios(request)
        .then(function (response) {
          console.log('Success')
          resolve(response.data)
        })
        .catch(function (error) {
          console.log('Fail')
          reject(error.response.data)
        })
    })
  }

  /**
   * Initiates a C2B (Client-to-Business) transaction on the M-Pesa API.
   *
   * @param {object} transaction_data
   * @param {float}  transaction_data.amount - Value to transfer from Client to Business
   * @param {string} transaction_data.msisdn - Client's phone number
   * @param {string} transaction_data.reference - Transaction reference (unique)
   * @param {string} transaction_data.third_party_reference - Third-party reference provided by Vodacom MZ
   * @throws 'Missing or invalid C2B parameters' Error if params are missing or invalid
   * @example
   * Transaction = require('mpesa-mz-nodejs-lib')
   * // Instantiate Transaction object with valid options params
   * tx = new Transaction(options)
   *
   * tx.c2b({
   * 	amount: 1,
   * 	msisdn: '821234567'
   * 	reference: 'T001',
   * 	third_party_reference: '12345'
   * }).then(function(data){
   * 	console.log(data)
   * }).catch(function(error){
   * 	console.log(error)
   * })
   *
   * @return {object} Promise
   */
  this.c2b = function (transaction_data) {
    if (this._isValidated('c2b', transaction_data)) {
      request = {
        method: 'post',
        url:
          'https://' + this._api_host + ':18352/ipg/v1x/c2bPayment/singleStage/',
        data: {
          input_ServiceProviderCode: this._service_provider_code,
          input_CustomerMSISDN: this._validMSISDN,
          input_Amount: parseFloat(transaction_data.amount).toFixed(2),
          input_TransactionReference: transaction_data.reference,
          input_ThirdPartyReference: transaction_data.third_party_reference
        },
        headers: this._request_headers
      }

      return this._requestAsPromiseFrom(request)
    } else {
      throw Error('Missing or invalid C2B parameters:' + this.validation_errors.toString())
    }
  }

  /**
   * Initiates a C2B (Client-to-Business) transaction Query on the M-Pesa API.
   *
   * @param {object} query_data
   * @param {string} query_data.query_reference - TransactionID or ConversationID returned from the M-Pesa API
   * @param {string} query_data.third_party_reference - Unique reference of the third-party system
   * @throws 'Missing or invalid Query parameters' Error is params are missing or invalid
   * @example
   * Transaction = require('mpesa-mz-nodejs-lib')
   * // Instantiate Transaction object with valid params
   * tx = new Transaction(options)
   *
   * tx.query({
   * 	query_reference:'08y844du6gs',
   * 	third_party_reference:'12345'
   * }).then(function(data){
   * 	console.log(data)
   * }).catch(function(error){
   * 	console.log(error)
   * })
   *
   * @return {object} Promise
   */
  this.query = function (query_data) {
    if (this._isValidated('query', query_data)) {
      request = {
        method: 'get',
        url:
          'https://' +
          this._api_host +
          ':18353/ipg/v1x/queryTransactionStatus/?input_QueryReference=' +
          query_data.query_reference +
          '&input_ServiceProviderCode=' +
          this._service_provider_code +
          '&input_ThirdPartyReference=' +
          query_data.third_party_reference,
        headers: this._request_headers
      }

      // If all transaction properties exist and are valid, return promise
      return this._requestAsPromiseFrom(request)
    } else {
      throw Error('Missing or invalid Query parameters:' + this.validation_errors.toString())
    }
  }

  /**
   * Initiates a C2B (Client-to-Business) transaction Reversal on the M-Pesa API
   *
   * @param {object} transaction_data
   * @param {number} [transaction_data.amount] - Amount of the transaction
   * @param {string} transaction_data.transaction_id - TransactionID returned from the M-Pesa API
   * @param {string} transaction_data.third_party_reference - Unique reference of the third-party system
   * @throws 'Missing or invalid Reversal parameters' Error if params are missing or invalid
   * @example
   * Transaction = require('mpesa-mz-nodejs-lib')
   * // Instantiate Transaction object with valid params
   * tx = new Transaction(options)
   *
   * tx.reversal({
   * 	amount:1,
   * 	transaction_id: 'tvfs2503x1d'
   * 	third_party_reference:'12345'
   * }).then(function(data){
   * console.log(data)
   * }).catch(function(error){
   * console.log(error)
   * })
   *
   * @return {object} Promise
   */
  this.reverse = function (transaction_data) {
    if (this._isValidated('reversal', transaction_data)) {
      request = {
        method: 'put',
        url: 'https://' + this._api_host + ':18354/ipg/v1x/reversal/',
        data: {
          input_ReversalAmount: Number.parseFloat(
            transaction_data.amount
          ).toFixed(2),
          input_TransactionID: transaction_data.transaction_id,
          input_ThirdPartyReference: transaction_data.third_party_reference,
          input_ServiceProviderCode: this._service_provider_code,
          input_InitiatorIdentifier: this._initiator_identifier,
          input_SecurityCredential: this._security_credential
        },
        headers: this._request_headers
      }

      return this._requestAsPromiseFrom(request)
    } else {
      throw Error('Missing or invalid Reversal parameters:' + this.validation_errors.toString())
    }
  }

  // Validate config data and throw config errors if any param is missing or invalid
  if (this._isValidated('config', {})) {
    this._request_headers = {
      'Content-Type': 'application/json',
      Origin: this._origin,
      Authorization: this._getBearerToken()
    }
  } else { throw Error('Missing or invalid configuration parameters:' + this.validation_errors.toString()) }
}