diff --git a/.babelrc b/.babelrc deleted file mode 100644 index 4897f9d..0000000 --- a/.babelrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "presets": ["stage-0", "es2015"] -} diff --git a/.bowerrc b/.bowerrc deleted file mode 100755 index 7c609d3..0000000 --- a/.bowerrc +++ /dev/null @@ -1,3 +0,0 @@ -{ - "directory": "public/vendor" -} diff --git a/.coveralls.yml b/.coveralls.yml new file mode 100644 index 0000000..28f81bd --- /dev/null +++ b/.coveralls.yml @@ -0,0 +1 @@ +repo_token: EI2vRz1HRhJ3pGi7g3J6sMxI4dsnrWxtb diff --git a/.eslintrc b/.eslintrc index b717627..4235824 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,67 +1,24 @@ { - "parser": "babel-eslint", - - "parserOptions": { - "sourceType": "module" - }, - - "extends": "eslint:recommended", - - "globals": {}, - - "env": { - "browser": true, - "node": true, - "es6": true, - "jasmine": true, - "mocha": true - }, - - "plugins": [], - - "ecmaFeatures": { - "arrowFunctions": true, - "binaryLiterals": true, - "blockBindings": true, - "classes": true, - "defaultParams": true, - "destructuring": true, - "forOf": true, - "generators": true, - "modules": true, - "objectLiteralComputedProperties": true, - - "objectLiteralDuplicateProperties": true, - "objectLiteralShorthandMethods": true, - "objectLiteralShorthandProperties": true, - "octalLiterals": true, - "regexUFlag": true, - "regexYFlag": true, - "spread": true, - "superInFunctions": true, - "templateStrings": true, - "unicodeCodePointEscapes": true, - "globalReturn": true, - "jsx": true - }, - - - "rules": { - "indent": 0, - "quotes": [2, "single"], - "linebreak-style": [2, "unix"], - "semi": [2, "always"], - "no-console": 0, - "no-case-declarations": 0, - "no-class-assign": 0, - "no-const-assign": 0, - "no-dupe-class-members": 0, - "no-empty-pattern": 0, - "no-new-symbol": 0, - "no-self-assign": 0, - "no-this-before-super": 0, - "no-unexpected-multiline": 0, - "no-unused-labels": 0, - "constructor-super": 0, - }, + "env": { + "node": true, + "mocha": true + }, + "extends": "airbnb", + "rules": { + "prefer-template": 0, + "prefer-rest-params": 0, + "strict": 0, + "no-unused-expressions": 0, + "no-param-reassign": 0, + "max-len": [ + 2, + 100, + 2, + { + "ignoreComments": true, + "ignoreUrls": true, + "ignorePattern": "\/(.*)\/;" + } + ] + } } diff --git a/.gitignore b/.gitignore index b93d42d..96ccb56 100644 --- a/.gitignore +++ b/.gitignore @@ -3,11 +3,15 @@ ## My additions .tmp .idea +.vscode node_modules -public +./public dist -./server/config/local.env.js +server/config/local.env.js npm-debug.log +.sass-cache +_site +app.yaml ### Added by loopback framework *.csv diff --git a/.jshintignore b/.jshintignore deleted file mode 100644 index dd5de77..0000000 --- a/.jshintignore +++ /dev/null @@ -1,2 +0,0 @@ -/server/client/ -/node_modules/ diff --git a/.jshintrc b/.jshintrc deleted file mode 100644 index 9cbe80c..0000000 --- a/.jshintrc +++ /dev/null @@ -1,48 +0,0 @@ -{ - "asi": false, - "bitwise": true, - "browser": true, - "camelcase": false, - "curly": true, - "forin": true, - "immed": true, - "latedef": "nofunc", - "maxlen": 120, - "newcap": true, - "noarg": true, - "noempty": true, - "nonew": true, - "predef": [ - "$", - "__dirname", - "after", - "afterEach", - "angular", - "assert", - "before", - "beforeEach", - "by", - "browser", - "chai", - "console", - "describe", - "element", - "expect", - "exports", - "it", - "inject", - "jQuery", - "jasmine", - "module", - "moment", - "process", - "require", - "should", - "sinon" - ], - "quotmark": true, - "strict": false, - "trailing": true, - "undef": true, - "unused": true -} diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..1da0cd6 --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: node index.js diff --git a/README.md b/README.md index 43038d1..d6e9f44 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,246 @@ -# Project Mulla +[![Coverage Status](https://coveralls.io/repos/github/kn9ts/project-mulla/badge.svg?branch=master)](https://coveralls.io/github/kn9ts/project-mulla?branch=master) +[![Build Status](https://semaphoreci.com/api/v1/kn9ts/project-mulla/branches/master/badge.svg)](https://semaphoreci.com/kn9ts/project-mulla) -__What MPESA G2 API should have been in the 21st century.__ +![](http://cdn.javascript.co.ke/images/banner.png) -__MPESA API RESTful mediator__. Basically converts all merchant requests to the dreaded ancient SOAP/XML -requests. It then mediates all communications to and from the Safaricom MPESA gateway frictionlessly. -Responding to the merchant via a beautiful and soothing 21st century REST API. +> __What MPESA G2 API should have been in the 21st century.__ -In short, it'll deal with all of the SOAP shinanigans while you REST. +> __PLEASE NOTE: Mediates only C2B portion for now.__ -The aim of __Project Mulla__, is to create a REST API that interfaces with the ugly MPESA G2 API. +

Project Mulla is a MPESA API RESTful mediator. It lets you make familiar HTTP REST requests, transforming your requests to the fiddling dreaded SOAP/XML requests that the Safaricom MPESA G2 API only understands. It then communicates with the MPESA API gateway, transforming all SOAP responses from the SAG to RESTful JSON responses that you then consume effortlessly.

+
In short, it’ll deal with all of the SOAP shenanigans while you REST. Everybody wins!
-Sounds like a broken reacord, but it was to emphasize what we hope to achieve :) +The aim of **Project Mulla** is to create a REST API middleman that interfaces with the **MPESA G2 API** for you. -### Since We Know, SOAP! Yuck! +### Yes We Know! SOAP! Yuck! -Developers should not go through the __trauma__ involved with dealing with SOAP/XML in the 21st century. +Developers should not go through the **trauma** involved with dealing with SOAP/XML in the 21st century. + +# Example of how it works + +## Request Payment + +This is initial step is to tell the SAG to initialise a payment you want to transact. After +initialisation, you then make another request to the SAG as a confirmation signaling the SAG to +process the payment request requested. + +Assuming __Project Mulla__ is now your mediator, you'd now make a __POST__ request to +__Project Mulla__. _Not the Safaricom Access Gateway_. + +See below how you'd make this initial request: + +### Initiate Payment Request: + +__`POST`__ __`https://project-mulla-companyname.herokuapp.com/api/v1/payment/request`__ + +_Body Parameters_: + +- `phoneNumber` - The phone number of your client +- `totalAmount` - The total amount you are charging the client +- `referenceID` [optional] - The reference ID of the order or service +- `merchantTransactionID` [optional] - This specific order's or service's transaction ID + +> __NOTE:__ If `merchantTransactionID` or `referenceID` are not provided a time-based and random +UUID is generated for each respectively. + +### Sample request using CURL in the command line/terminal: + +```bash +$ curl -i -X POST \ +--url http://project-mulla-companyname.herokuapp.com/api/v1/payment/request \ +--data 'phoneNumber=254723000000' \ +--data 'totalAmount=45.00' \ +--data 'clientName="Eugene Mutai"' \ +--data 'clientLocation=Kilimani' \ +``` + +### Expected Response + +If all goes well you get HTTP status code __`200`__ accompanied with the a similar structured JSON response: + +```json +{ + "response": { + "return_code": "00", + "status_code": 200, + "message": "Transaction carried successfully", + "trx_id": "453c70c4b2434bd94bcbafb17518dc8e", + "description": "success", + "cust_msg": "to complete this transaction, enter your bonga pin on your handset. if you don't have one dial *126*5# for instructions", + "reference_id": "3e3beff0-fc05-417a-bbf2-190ee19a5e58", + "merchant_transaction_id": "95d64500-2514-11e6-bcb8-a7f8e1c786c4", + "amount_in_double_float": "45.00", + "client_phone_number": "254723001575", + "extra_payload": {}, + "time_stamp": "20160528234142" + } +} +``` + +## Next step: confirmation + +You are to use `trx_id` or `merchant_transaction_id` to make the confirmation payment +request. The confirmation request is the request the payment requested above to be processed and +triggers a pop up on the your client's mobile phone. + +[Find the complete documentation here](http://kn9ts.github.io/project-mulla/docs) + +# Installation & Testing + +# Installation + +Installing Project Mulla is easy and straight-forward, but there are a few requirements you’ll need +to make sure your system has before you start. + +## Requirements + +You will need to install some stuff, if they are not yet installed in your machine: + +* [Node.js (v4.3.2 or higher; LTS)](http://nodejs.org) +* [NPM (v3.5+; bundled with node.js installation package)](https://docs.npmjs.com/getting-started/installing-node#updating-npm) + +If you've already installed the above you may need to only update **npm** to the latest version: + +```bash +$ sudo npm update -g npm +``` --- -### This project uses GPL3 LICENSE +## Install with Github -You saw the __LICENSE__ but you were like __*TL;DR*__. Here's sort of WHY I have used this restricting license: +Best way to install Project Mulla is to clone it from Github -```markdown -1. If you disagree with it then don’t use my software. It’s as simple as that. +**To clone/download the boilerplate** -2. I almost need people, organisations and companies to have to admit they use my software. +```bash +$ git clone https://github.com/kn9ts/project-mulla.git +``` + +**After cloning, get into your cloned Project Mulla's directory/folder** -3. I have no social incentive to "give" my software free to companies just after nothing else but profit. - Open source to open source, corporation to corporation. +```bash +$ cd project-mulla +``` -4. To keep you honest. You now have to tell your bosses you’re using my gear. And it will scare - the shit out of them. Why? They risk opensourcing all of their codebase. +**Install all of the projects dependencies with:** -5. Some companies who use our software do not give back. The irony of the situation is that, - in order to improve my motivation to do open source, I have to charge for it. +```bash +$ npm install ``` -*__PLEASE NOTE:__ All opinions aired in this repo are ours and do not reflect any company or organisation any contributor is involved with.* +__Create `app.yaml` configurations file__ + +The last but not least step is creating a `app.yaml` file with your configurations in the root +directory of `project-mulla`. + +This is the same folder estate where `index.js` can be found. + +It should look like the example below, only with your specific config values: + +```yaml +env_variables: + PAYBILL_NUMBER: '898998' + PASSKEY: 'a8eac82d7ac1461ba0348b0cb24d3f8140d3afb9be864e56a10d7e8026eaed66' + MERCHANT_ENDPOINT: 'http://merchant-endpoint.com/mpesa/payment/complete' + +# Everything below is only relevant if you are looking +# to deploy Project Mulla to Google App Engine. +runtime: nodejs +vm: true + +skip_files: + - ^(.*/)?.*/node_modules/.*$ +``` + +*__NOTE:__ The `PAYBILL_NUMBER` and `PASSKEY` are provided by Safaricom once you have registered for the MPESA G2 API.* + +*__NOTE:__ The details above only serve as examples* + +## It's now ready to launch + +1st run the command `npm test` in the console and see if everything is all good. Then run: + +```bash +$ npm start + +> project-mulla@0.1.1 start ../project-mulla +> node index.js + +Your secret session key is: 5f06b1f1-1bff-470d-8198-9ca2f18919c5 +Express server listening on 8080, in development mode +``` + +## Do a test run + +Now make a test run using **CURL**: + +```bash +$ curl -i -X POST \ + --url http://localhost:8080/api/v1/payment/request \ + --data 'phoneNumber=254723000000' \ + --data 'totalAmount=10.00' \ + --data 'clientName="Eugene Mutai"' \ + --data 'clientLocation=Kilimani' \ +``` + +Or if you have [httpie](https://github.com/jkbrzt/httpie) installed: + +```bash +$ http POST localhost:8080/api/v1/payment/request \ + phoneNumber=254723000000 \ + totalAmount=10.00 \ + clientName='Eugene Mutai' \ + clientLocation='Kilimani' +``` + +Once the request is executed, your console should print a similar structured **response** as below: + +```http +HTTP/1.1 200 OK +Connection: keep-alive +Content-Length: 534 +Content-Type: application/json; charset=utf-8 +Date: Sun, 22 May 2016 13:12:09 GMT +ETag: W/"216-NgmF2VWb0PIkUOKfya6WlA" +X-Powered-By: Express +set-cookie: connect.sid=s:iWfXH7rbAvXz7cYgmurhGTHDn0LNBmNt; Path=/; HttpOnly + +{ + "response": { + "return_code": "00", + "status_code": 200, + "message": "Transaction carried successfully", + "trx_id": "453c70c4b2434bd94bcbafb17518dc8e", + "description": "success", + "cust_msg": "to complete this transaction, enter your bonga pin on your handset. if you don't have one dial *126*5# for instructions", + "reference_id": "3e3beff0-fc05-417a-bbf2-190ee19a5e58", + "merchant_transaction_id": "95d64500-2514-11e6-bcb8-a7f8e1c786c4", + "amount_in_double_float": "10.00", + "client_phone_number": "254723001575", + "extra_payload": {}, + "time_stamp": "20160528234142" + } +} +``` + + +# This project uses GPLv3 LICENSE + +__TL;DR__ Here's what the license entails: + +```markdown +1. Anyone can copy, modify and distribute this software. +2. You have to include the license and copyright notice with each and every distribution. +3. You can use this software privately. +4. You can use this software for commercial purposes. +5. If you dare build your business solely from this code, you risk open-sourcing the whole code base. +6. If you modify it, you have to indicate changes made to the code. +7. Any modifications of this code base MUST be distributed with the same license, GPLv3. +8. This software is provided without warranty. +9. The software author or license can not be held liable for any damages inflicted by the software. +``` + +More information on the [LICENSE can be found here](http://choosealicense.com/licenses/gpl-3.0/) + +*__DISCLAIMER:__* _All opinions aired in this repo are ours and do not reflect any company or organisation any contributor is involved with._ diff --git a/Checkout.wsdl b/docs/Checkout.wsdl similarity index 100% rename from Checkout.wsdl rename to docs/Checkout.wsdl diff --git a/docs/banner.png b/docs/banner.png new file mode 100644 index 0000000..1fdaea7 Binary files /dev/null and b/docs/banner.png differ diff --git a/docs/requests/1-process-checkout.xml b/docs/requests/1-process-checkout.xml index 313def9..75ced85 100644 --- a/docs/requests/1-process-checkout.xml +++ b/docs/requests/1-process-checkout.xml @@ -2,7 +2,6 @@ 898945 - MmRmNTliMjIzNjJhNmI5ODVhZGU5OTAxYWQ4NDJkZmI2MWE4ODg1ODFhMTQ3ZmZmNTFjMjg4M2UyYWQ5NTU3Yw== 20141128174717 @@ -13,7 +12,6 @@ 1112254500 54 2547204871865 - http://172.21.20.215:8080/test xml diff --git a/docs/responses/2-process-checkout-response.xml b/docs/responses/2-process-checkout-response.xml new file mode 100644 index 0000000..a42ac24 --- /dev/null +++ b/docs/responses/2-process-checkout-response.xml @@ -0,0 +1,11 @@ + + + + 00 + Success + cce3d32e0159c1e62a9ec45b67676200 + + To complete this transaction, enter your Bonga PIN on your handset. if you don't have one dial *126*5# for instructions + + + diff --git a/docs/responses/4-transaction-confirmed.xml b/docs/responses/4-transaction-confirmed.xml index 6d57f78..7e7c7fb 100644 --- a/docs/responses/4-transaction-confirmed.xml +++ b/docs/responses/4-transaction-confirmed.xml @@ -3,7 +3,7 @@ 00 Success - + 5f6af12be0800c4ffabb4cf2608f0808 diff --git a/docs/responses/5-transaction-completed.xml b/docs/responses/5-transaction-completed.xml index 02decc9..6f6976c 100644 --- a/docs/responses/5-transaction-completed.xml +++ b/docs/responses/5-transaction-completed.xml @@ -14,11 +14,3 @@ - - diff --git a/docs/statuses/transaction-status-query.xml b/docs/statuses/transaction-status-query.xml new file mode 100644 index 0000000..055b5aa --- /dev/null +++ b/docs/statuses/transaction-status-query.xml @@ -0,0 +1,15 @@ + + + + 898945 + MmRmNTliMjIzNjJhNmI5ODVhZGU5OTAxYWQ4NDJkZmI2MWE4ODg1ODFhMTQ3ZmZmNTFjMjg4M2UyYWQ5NTU3Yw== + 20141128174717 + + + + + ddd396509b168297141a747cd2dc1748 + 911-100 + + + diff --git a/docs/statuses/transaction-status-response.xml b/docs/statuses/transaction-status-response.xml new file mode 100644 index 0000000..bdd67f7 --- /dev/null +++ b/docs/statuses/transaction-status-response.xml @@ -0,0 +1,16 @@ + + + + 254720471865 + 54000 + 2014-12-01 16:59:07 + ddd396509b168297141a747cd2dc1748 + Failed + 01 + InsufficientFunds + + + ddd396509b168297141a747cd2dc1748 + + + diff --git a/environment.js b/environment.js index 40b7d4e..600b84f 100644 --- a/environment.js +++ b/environment.js @@ -1,6 +1,59 @@ -import dotenv from 'dotenv'; +'use strict'; +const fs = require('fs'); +const yaml = require('js-yaml'); +const uuid = require('node-uuid'); -if ((process.env.NODE_ENV || 'development') === 'development') { - // load the applications environment - dotenv.load(); +const yamlConfigFile = 'app.yaml'; + +// default configuration +process.env.API_VERSION = 1; +process.env.ENDPOINT = 'https://safaricom.co.ke/mpesa_online/lnmo_checkout_server.php?wsdl'; +process.env.SESSION_SECRET_KEY = uuid.v4(); + +// if an env has not been provided, default to development +if (!('NODE_ENV' in process.env)) process.env.NODE_ENV = 'development'; + +if (process.env.NODE_ENV === 'development') { + const requiredEnvVariables = [ + 'PAYBILL_NUMBER', + 'PASSKEY', + 'MERCHANT_ENDPOINT', + ]; + const envKeys = Object.keys(process.env); + const requiredEnvVariablesExist = requiredEnvVariables + .every(variable => envKeys.indexOf(variable) !== -1); + + // if the requiredEnvVariables have not been added + // maybe by GAE or Heroku ENV settings + if (!requiredEnvVariablesExist) { + if (fs.existsSync(yamlConfigFile)) { + // Get the rest of the config from app.yaml config file + const config = yaml.safeLoad(fs.readFileSync(yamlConfigFile, 'utf8')); + Object.keys(config.env_variables).forEach(key => { + process.env[key] = config.env_variables[key]; + }); + } else { + throw new Error(` + Missing app.yaml config file used while in development mode + + It should have contents similar to the example below: + + app.yaml + ------------------------- + env_variables: + PAYBILL_NUMBER: '000000' + PASSKEY: 'a8eac82d7ac1461ba0348b0cb24d3f8140d3afb9be864e56a10d7e8026eaed66' + MERCHANT_ENDPOINT: 'http://merchant-endpoint.com/mpesa/payment/complete' + + # Everything below from this point onwards are only relevant + # if you are looking to deploy Project Mulla to Google App Engine. + runtime: nodejs + vm: true + + skip_files: + - ^(.*/)?.*/node_modules/.*$ + ------------------------- + `); + } + } } diff --git a/gulpfile.js b/gulpfile.js new file mode 100644 index 0000000..1757406 --- /dev/null +++ b/gulpfile.js @@ -0,0 +1,49 @@ +'use strict'; + +require('./environment'); +const os = require('os'); +const gulp = require('gulp'); +const mocha = require('gulp-mocha'); +const istanbul = require('gulp-istanbul'); +const coveralls = require('gulp-coveralls'); +const eslint = require('gulp-eslint'); +const runSequence = require('run-sequence'); + +if (!('COVERALLS_SERVICE_NAME' in process.env)) { + process.env.COVERALLS_SERVICE_NAME = `${os.hostname()}.${os.platform()}-${os.release()}`; +} +process.env.COVERALLS_REPO_TOKEN = 'EI2vRz1HRhJ3pGi7g3J6sMxI4dsnrWxtb'; + +const filesToLint = [ + 'gulpfile.js', + 'index.js', + 'environment.js', + './server/**/*.js', + '!node_modules/**', +]; + +gulp.task('lint', () => gulp.src(filesToLint) + .pipe(eslint()) + .pipe(eslint.format()) + .pipe(eslint.failAfterError())); + +gulp.task('coverage', () => gulp + .src(['!node_modules/**', '!server/routes/**', './server/**/*.js']) + .pipe(istanbul({ includeUntested: true })) + .pipe(istanbul.hookRequire())); + +gulp.task('test:backend', () => gulp.src(['test/**/*.js']) + .pipe(mocha({ reporter: 'spec' })) + .once('error', err => { + throw err; + }) + .pipe(istanbul.writeReports({ + dir: './coverage', + reporters: ['html', 'lcov', 'text', 'json'], + }))); + +gulp.task('coveralls', () => gulp.src('coverage/lcov.info').pipe(coveralls())); + +gulp.task('test', callback => { + runSequence('lint', 'coverage', 'test:backend', callback); +}); diff --git a/index.js b/index.js index e20d129..2867dd3 100644 --- a/index.js +++ b/index.js @@ -1,86 +1,89 @@ -import './environment'; -import express from 'express'; -import path from 'path'; -import configSetUp from './config'; -// import favicon from 'serve-favicon'; -import morgan from 'morgan'; -import cookieParser from 'cookie-parser'; -import bodyParser from 'body-parser'; -import session from 'express-session'; -import connectMongo from 'connect-mongo'; -import models from './models'; -import routes from './routes'; - +'use strict'; +require('./environment'); +const express = require('express'); const app = express(); -const apiVersion = 1; -const config = configSetUp(process.env.NODE_ENV); -const MongoStore = connectMongo(session); - -// -- make the models available everywhere in the app -- -app.set('models', models); -app.set('webTokenSecret', config.webTokenSecret); +const path = require('path'); +const morgan = require('morgan'); +const cookieParser = require('cookie-parser'); +const bodyParser = require('body-parser'); +const session = require('express-session'); +const routes = require('./server/routes'); +const genTransactionPassword = require('./server/utils/genTransactionPassword'); +const apiVersion = process.env.API_VERSION; // view engine setup -app.set('views', path.join(__dirname, 'views')); +app.set('views', path.join(__dirname, 'server/views')); app.set('view engine', 'jade'); +// trust proxy if it's being served in GoogleAppEngine +if ('GAE_APPENGINE_HOSTNAME' in process.env) app.set('trust_proxy', 1); + // Uncomment this for Morgan to intercept all Error instantiations // For now, they churned out via a JSON response -// app.use(morgan('dev')); +app.use(morgan('dev')); app.use(bodyParser.json()); -app.use(bodyParser.urlencoded({ - extended: false -})); +app.use(bodyParser.urlencoded({ extended: true })); app.use(cookieParser()); // uncomment after placing your favicon in /public // app.use(favicon(path.join(__dirname, 'public', 'favicon.ico'))); // not using express less // app.use(require('less-middleware')(path.join(__dirname, 'server/public'))); -app.use(express.static(path.join(__dirname, './public'))); +app.use(express.static(path.join(__dirname, './server/public'))); + +// memory based session app.use(session({ - secret: config.expressSessionKey, - maxAge: new Date(Date.now() + 3600000), - proxy: true, - resave: true, + secret: process.env.SESSION_SECRET_KEY, + resave: false, saveUninitialized: true, - store: new MongoStore({ - mongooseConnection: models.mongoose.connection - }) })); +// on payment transaction requests, +// generate and password to req object +app.use(`/api/v${apiVersion}/payment*`, genTransactionPassword); + // get an instance of the router for api routes -app.use(`/api/v${apiVersion}`, routes(express.Router())); +const apiRouter = express.Router; +app.use(`/api/v${apiVersion}`, routes(apiRouter())); + +app.all('/*', (req, res) => { + res.render('index', { title: 'Project Mulla' }); +}); + +// use this prettify the error stack string into an array of stack traces +const prettifyStackTrace = stackTrace => stackTrace.replace(/\s{2,}/g, ' ').trim(); // catch 404 and forward to error handler app.use((req, res, next) => { - let err = new Error('Not Found'); - err.request = req.originalUrl; - err.status = 404; + const err = new Error('Not Found'); + err.statusCode = 404; next(err); }); // error handlers -app.use((err, req, res) => { - res.status(err.status || 500); - // get the error stack - let stack = err.stack.split(/\n/).map((err) => { - return err.replace(/\s{2,}/g, ' ').trim(); - }); - console.log('ERROR PASSING THROUGH', err.message); - // send out the error as json - res.json({ - api: err, - url: req.originalUrl, - error: err.message, - stack: stack - }); +app.use((err, req, res, next) => { + if (typeof err === 'undefined') next(); + console.log('An error occured: ', err.message); + const errorResponse = { + status_code: err.statusCode, + request_url: req.originalUrl, + message: err.message, + }; + + // Only send back the error stack if it's on development mode + if (process.env.NODE_ENV === 'development') { + const stack = err.stack.split(/\n/).map(prettifyStackTrace); + errorResponse.stack_trace = stack; + } + + return res.status(err.statusCode || 500).json(); }); -var server = app.listen(process.env.PORT || 3000, () => { +const server = app.listen(process.env.PORT || 8080, () => { + console.log('Your secret session key is: ' + process.env.SESSION_SECRET_KEY); console.log('Express server listening on %d, in %s' + ' mode', server.address().port, app.get('env')); }); -//expose app -export {app as default}; +// expose app +module.exports = app; diff --git a/package.json b/package.json index c13d5ce..656ab48 100644 --- a/package.json +++ b/package.json @@ -5,10 +5,11 @@ "private": true, "main": "index.js", "scripts": { - "develop": "nodemon -w ./server --exec npm start", - "lint": "eslint ./server", - "prestart": "./node_modules/.bin/babel ./server ./*.js --out-dir dist", - "start": "node dist/index.js" + "test": "gulp test", + "develop": "nodemon -w ./server -w index.js -w environment.js --exec npm start", + "lint": "eslint --fix ./server ./test", + "monitor": "nodemon index.js", + "start": "node index.js" }, "repository": { "type": "git", @@ -23,28 +24,39 @@ "dependencies": { "body-parser": "~1.13.2", "cheerio": "^0.20.0", - "connect-mongo": "^1.1.0", "cookie-parser": "~1.3.5", "debug": "~2.2.0", - "dotenv": "^2.0.0", - "express": "~4.13.1", + "express": "^4.13.4", "express-session": "^1.13.0", "jade": "~1.11.0", - "lodash": "^4.12.0", + "js-yaml": "^3.6.1", "moment": "^2.13.0", - "mongoose": "^4.4.16", "morgan": "~1.6.1", "node-uuid": "^1.4.7", "request": "^2.72.0", "serve-favicon": "~2.3.0" }, "devDependencies": { - "babel-cli": "^6.7.7", - "babel-eslint": "^6.0.4", - "babel-preset-es2015": "^6.6.0", - "babel-preset-stage-0": "^6.5.0", - "eslint": "^2.8.0", + "chai": "^3.5.0", + "coveralls": "^2.11.9", + "eslint": "^2.10.2", + "eslint-config-airbnb": "^9.0.1", + "eslint-plugin-import": "^1.8.0", + "eslint-plugin-jsx-a11y": "^1.2.2", "eslint-plugin-react": "^5.1.1", - "nodemon": "^1.9.2" + "gulp": "^3.9.1", + "gulp-coveralls": "^0.1.4", + "gulp-eslint": "^2.0.0", + "gulp-istanbul": "^0.10.4", + "gulp-mocha": "^2.2.0", + "istanbul": "^0.4.3", + "mocha": "^2.5.2", + "nodemon": "^1.9.2", + "run-sequence": "^1.2.1", + "sinon": "^1.17.4" + }, + "engines": { + "node": "^4.3.2", + "npm": "^3.9.5" } } diff --git a/server/config/database.js b/server/config/database.js deleted file mode 100644 index efe1b33..0000000 --- a/server/config/database.js +++ /dev/null @@ -1,27 +0,0 @@ -import Mongoose from 'mongoose'; -Mongoose.connect(process.env.DATABASE); - -// When successfully connected -Mongoose.connection.on('connected', function() { - console.log('Mongoose has connected to the database specified.'); -}); - -// If the connection throws an error -Mongoose.connection.on('error', function(err) { - console.log('Mongoose default connection error: ' + err); -}); - -// When the connection is disconnected -Mongoose.connection.on('disconnected', function() { - console.log('Mongoose default connection disconnected'); -}); - -// If the Node process ends, close the Mongoose connection -process.on('SIGINT', function() { - Mongoose.connection.close(function() { - console.log('Mongoose disconnected on application exit'); - process.exit(0); - }); -}); - -export { Mongoose as default }; diff --git a/server/config/index.js b/server/config/index.js deleted file mode 100644 index 5094d59..0000000 --- a/server/config/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export default function(value) { - var envVariables = { - host: process.env.HOST, - database: process.env.DATABASE, - expressSessionKey: process.env.EXPRESS_SESSION_KEY, - webTokenSecret: process.env.WEB_TOKEN_SECRET - }, - environments = { - development: envVariables, - staging: envVariables, - production: envVariables - }; - return environments[value] ? environments[value] : environments.development; -} diff --git a/server/config/status-codes.js b/server/config/status-codes.js deleted file mode 100644 index 95468b0..0000000 --- a/server/config/status-codes.js +++ /dev/null @@ -1,85 +0,0 @@ -export default [{ - returnCode: 0, - httpCode: 200, - message: 'Transaction carried successfully' -}, { - returnCode: 9, - httpCode: 400, - message: 'The merchant ID provided does not exist in our systems' -}, { - returnCode: 10, - httpCode: 400, - message: 'The phone number(MSISDN) provided isn’t registered on M-PESA' -}, { - returnCode: 30, - httpCode: 400, - message: 'Missing reference ID' -}, { - returnCode: 31, - httpCode: 400, - message: 'The request amount is invalid or blank' -}, { - returnCode: 36, - httpCode: 400, - message: 'Incorrect credentials are provided in the request' -}, { - returnCode: 40, - httpCode: 400, - message: 'Missing required parameters' -}, { - returnCode: 41, - httpCode: 400, - message: 'MSISDN(phone number) is in incorrect format' -}, { - returnCode: 32, - httpCode: 401, - message: 'The merchant/paybill account in the request hasn’t been activated' -}, { - returnCode: 33, - httpCode: 401, - message: 'The merchant/paybill account hasn’t been approved to transact' -}, { - returnCode: 1, - httpCode: 402, - message: 'Client has insufficient funds to complete the transaction' -}, { - returnCode: 3, - httpCode: 402, - message: 'The amount to be transacted is less than the minimum single transfer amount allowed' -}, { - returnCode: 4, - httpCode: 402, - message: 'The amount to be transacted is more than the maximum single transfer amount allowed' -}, { - returnCode: 8, - httpCode: 402, - message: 'The client has reached his/her maximum transaction limit for the day' -}, { - returnCode: 35, - httpCode: 409, - message: 'A duplicate request has been detected' -}, { - returnCode: 12, - httpCode: 409, - message: 'The transaction details are different from original captured request details' -}, { - returnCode: 6, - httpCode: 503, - message: 'Transaction could not be confirmed possibly due to the operation failing' -}, { - returnCode: 11, - httpCode: 503, - message: 'The system is unable to complete the transaction' -}, { - returnCode: 34, - httpCode: 503, - message: 'A delay is being experienced while processing requests' -}, { - returnCode: 29, - httpCode: 503, - message: 'The system is inaccessible; The system may be down' -}, { - returnCode: 5, - httpCode: 504, - message: 'Duration provided to complete the transaction has expired' -}]; diff --git a/server/config/statusCodes.js b/server/config/statusCodes.js new file mode 100644 index 0000000..8c08c82 --- /dev/null +++ b/server/config/statusCodes.js @@ -0,0 +1,99 @@ +'use strict'; + +module.exports = [{ + return_code: 0, + status_code: 200, + message: 'Transaction carried successfully', +}, { + return_code: 9, + status_code: 400, + message: 'The merchant ID provided does not exist in our systems', +}, { + return_code: 10, + status_code: 400, + message: 'The phone number(MSISDN) provided isn’t registered on M-PESA', +}, { + return_code: 30, + status_code: 400, + message: 'Missing reference ID', +}, { + return_code: 31, + status_code: 400, + message: 'The request amount is invalid or blank', +}, { + return_code: 36, + status_code: 400, + message: 'Incorrect credentials are provided in the request', +}, { + return_code: 40, + status_code: 400, + message: 'Missing required parameters', +}, { + return_code: 41, + status_code: 400, + message: 'MSISDN(phone number) is in incorrect format', +}, { + return_code: 42, + status_code: 400, + message: 'Your PASSKEY, PAYBILL_NUMBER or environment variables may be incorrect', +}, { + return_code: 99, + status_code: 400, + message: 'There\'s no recorded transaction associated with the transaction ID provided', +}, { + return_code: 32, + status_code: 401, + message: 'The merchant/paybill account in the request hasn’t been activated', +}, { + return_code: 33, + status_code: 401, + message: 'The merchant/paybill account hasn’t been approved to transact', +}, { + return_code: 1, + status_code: 402, + message: 'Client has insufficient funds to complete the transaction', +}, { + return_code: 3, + status_code: 402, + message: 'The amount to be transacted is less than the minimum single transfer amount allowed', +}, { + return_code: 4, + status_code: 402, + message: 'The amount to be transacted is more than the maximum single transfer amount allowed', +}, { + return_code: 8, + status_code: 402, + message: 'The client has reached his/her maximum transaction limit for the day', +}, { + return_code: 35, + status_code: 409, + message: 'A duplicate request has been detected', +}, { + return_code: 43, + status_code: 409, + message: 'Duplicate merchant transaction ID detected', +}, { + return_code: 12, + status_code: 409, + message: 'The transaction details are different from original captured request details', +}, { + return_code: 6, + status_code: 503, + message: 'Transaction could not be confirmed possibly due to the operation failing', +}, { + return_code: 11, + status_code: 503, + message: 'The system is unable to complete the transaction', +}, { + return_code: 34, + status_code: 503, + message: 'A delay is being experienced while processing requests', +}, { + return_code: 29, + status_code: 503, + message: 'The system is inaccessible; The system may be down', +}, { + return_code: 5, + status_code: 504, + message: 'Duration provided to complete the transaction has expired', +}]; diff --git a/server/controllers/ConfirmPayment.js b/server/controllers/ConfirmPayment.js new file mode 100644 index 0000000..27addc4 --- /dev/null +++ b/server/controllers/ConfirmPayment.js @@ -0,0 +1,55 @@ +'use strict'; + +const ParseResponse = require('../utils/ParseResponse'); +const SOAPRequest = require('../utils/SOAPRequest'); +const responseError = require('../utils/errors/responseError'); + +const parseResponse = new ParseResponse('transactionconfirmresponse'); +const soapRequest = new SOAPRequest(); + +class ConfirmPayment { + constructor(request, parser) { + this.parser = parser; + this.soapRequest = request; + } + + buildSoapBody(data) { + const transactionConfirmRequest = typeof data.transactionID !== 'undefined' ? + '' + data.transactionID + '' : + '' + data.merchantTransactionID + ''; + + this.body = ` + + + ${process.env.PAYBILL_NUMBER} + ${data.encryptedPassword} + ${data.timeStamp} + + + + + ${transactionConfirmRequest} + + + `; + + return this; + } + + handler(req, res) { + const paymentDetails = { + transactionID: req.params.id, // eg. '99d0b1c0237b70f3dc63f36232b9984c' + timeStamp: req.timeStamp, + encryptedPassword: req.encryptedPassword, + }; + const payment = this.buildSoapBody(paymentDetails); + const confirm = this.soapRequest.construct(payment, this.parser); + + // process ConfirmPayment response + return confirm.post() + .then(response => res.status(200).json({ response })) + .catch(error => responseError(error, res)); + } +} + +module.exports = new ConfirmPayment(soapRequest, parseResponse); diff --git a/server/controllers/PaymentRequest.js b/server/controllers/PaymentRequest.js new file mode 100644 index 0000000..b1adbf1 --- /dev/null +++ b/server/controllers/PaymentRequest.js @@ -0,0 +1,82 @@ +'use strict'; + +const uuid = require('node-uuid'); +const ParseResponse = require('../utils/ParseResponse'); +const SOAPRequest = require('../utils/SOAPRequest'); +const responseError = require('../utils/errors/responseError'); + +const parseResponse = new ParseResponse('processcheckoutresponse'); +const soapRequest = new SOAPRequest(); + +class PaymentRequest { + constructor(request, parser) { + this.parser = parser; + this.soapRequest = request; + this.callbackMethod = 'POST'; + } + + buildSoapBody(data) { + this.body = ` + + + ${process.env.PAYBILL_NUMBER} + ${data.encryptedPassword} + ${data.timeStamp} + + + + + ${data.merchantTransactionID} + + ${String(data.referenceID).slice(0, 8)} + ${data.amountInDoubleFloat} + ${data.clientPhoneNumber} + ${JSON.stringify(data.extraPayload)} + ${data.callbackURL} + ${this.callbackMethod} + ${data.timeStamp} + + + `; + + return this; + } + + handler(req, res) { + const paymentDetails = { + // transaction reference ID + referenceID: (req.body.referenceID || uuid.v4()), + // product, service or order ID + merchantTransactionID: (req.body.merchantTransactionID || uuid.v1()), + amountInDoubleFloat: req.body.totalAmount, + clientPhoneNumber: req.body.phoneNumber, + extraPayload: req.body.extraPayload, + timeStamp: req.timeStamp, + encryptedPassword: req.encryptedPassword, + callbackURL: `${req.protocol}://${req.hostname}/api/v${process.env.API_VERSION}/payment/success`, + }; + + const payment = this.buildSoapBody(paymentDetails); + const request = this.soapRequest.construct(payment, this.parser); + + // remove encryptedPassword + delete paymentDetails.encryptedPassword; + + // convert paymentDetails properties to underscore notation + const returnThesePaymentDetails = {}; + for (const key of Object.keys(paymentDetails)) { + const newkey = key.replace(/[A-Z]{1,}/g, match => '_' + match.toLowerCase()); + returnThesePaymentDetails[newkey] = paymentDetails[key]; + delete paymentDetails[key]; + } + + // make the payment requets and process response + return request.post() + .then(response => res.status(200).json({ + response: Object.assign({}, response, returnThesePaymentDetails), + })) + .catch(error => responseError(error, res)); + } +} + +module.exports = new PaymentRequest(soapRequest, parseResponse); diff --git a/server/controllers/PaymentStatus.js b/server/controllers/PaymentStatus.js new file mode 100644 index 0000000..2b4e0df --- /dev/null +++ b/server/controllers/PaymentStatus.js @@ -0,0 +1,55 @@ +'use strict'; + +const ParseResponse = require('../utils/ParseResponse'); +const SOAPRequest = require('../utils/SOAPRequest'); +const responseError = require('../utils/errors/responseError'); + +const parseResponse = new ParseResponse('transactionstatusresponse'); +const soapRequest = new SOAPRequest(); + +class PaymentStatus { + constructor(request, parser) { + this.parser = parser; + this.soapRequest = request; + } + + buildSoapBody(data) { + const transactionStatusRequest = typeof data.transactionID !== 'undefined' ? + '' + data.transactionID + '' : + '' + data.merchantTransactionID + ''; + + this.body = ` + + + ${process.env.PAYBILL_NUMBER} + ${data.encryptedPassword} + ${data.timeStamp} + + + + + ${transactionStatusRequest} + + + `; + + return this; + } + + handler(req, res) { + const paymentDetails = { + transactionID: req.params.id, + timeStamp: req.timeStamp, + encryptedPassword: req.encryptedPassword, + }; + const payment = this.buildSoapBody(paymentDetails); + const status = this.soapRequest.construct(payment, this.parser); + + // process PaymentStatus response + return status.post() + .then(response => res.status(200).json({ response })) + .catch(error => responseError(error, res)); + } +} + +module.exports = new PaymentStatus(soapRequest, parseResponse); diff --git a/server/controllers/PaymentSuccess.js b/server/controllers/PaymentSuccess.js new file mode 100644 index 0000000..275142c --- /dev/null +++ b/server/controllers/PaymentSuccess.js @@ -0,0 +1,51 @@ +'use strict'; + +const request = require('request'); + +class PaymentSuccess { + constructor() { + this.request = request; + } + + handler(req, res, next) { + const keys = Object.keys(req.body); + const response = {}; + const baseURL = `${req.protocol}://${req.hostname}:${process.env.PORT || 8080}`; + let endpoint = `${baseURL}/api/v1/thumbs/up`; + + if ('MERCHANT_ENDPOINT' in process.env) { + endpoint = process.env.MERCHANT_ENDPOINT; + } else { + if (process.env.NODE_ENV !== 'development') { + next(new Error('MERCHANT_ENDPOINT has not been provided in environment configuration')); + return; + } + } + + for (const x of keys) { + const prop = x.toLowerCase().replace(/\-/g, ''); + response[prop] = req.body[x]; + } + + const requestParams = { + method: 'POST', + uri: endpoint, + rejectUnauthorized: false, + body: JSON.stringify(response), + headers: { + 'content-type': 'application/json; charset=utf-8', + }, + }; + + // make a request to the merchant's endpoint + this.request(requestParams, (error) => { + if (error) { + res.sendStatus(500); + return; + } + res.sendStatus(200); + }); + } +} + +module.exports = new PaymentSuccess(); diff --git a/server/controllers/errorhandler.js b/server/controllers/errorhandler.js deleted file mode 100644 index 5f3ea5a..0000000 --- a/server/controllers/errorhandler.js +++ /dev/null @@ -1,7 +0,0 @@ -export default class ResponseError{ - static handler(_error, res) { - let err = new Error('description' in _error ? _error.description : _error); - err.status = 'httpCode' in _error ? _error.httpCode : 500; - return res.status(err.status).json({ response: _error }); - } -} diff --git a/server/controllers/parse-response.js b/server/controllers/parse-response.js deleted file mode 100644 index 80ae67c..0000000 --- a/server/controllers/parse-response.js +++ /dev/null @@ -1,59 +0,0 @@ -import cheerio from 'cheerio'; -import _ from 'lodash'; -import statusCodes from '../config/status-codes'; - - -export default class ParseResponse { - constructor(soapResponse, bodyTagName) { - this.bodyTagName = bodyTagName; - // Remove the XML header tag - soapResponse = soapResponse.replace(/\<\?[\w\s\=\.\-\'\"]+\?\>/gmi, ''); - - // Get the element PREFIXES from the soap wrapper - let soapInstance = soapResponse.match(/(\<([\w\-]+\:[\w\-]+\s)([\w\=\-\:\"\'\\\/\.]+\s?)+?\>)/gi); - let soapPrefixes = soapInstance[0].match(/((xmlns):[\w\-]+)+/gi); - soapPrefixes = soapPrefixes.map((prefix) => { - return prefix.split(':')[1].replace(/\s+/gi, ''); - }); - - // Now clean the SOAP elements in the response - soapPrefixes.forEach((prefix) => { - soapResponse = soapResponse.replace(new RegExp(prefix + ':', 'gmi'), ''); - }); - - // Remove xmlns from the soap wrapper - soapResponse = soapResponse.replace(/(xmlns)\:/gmi, ''); - - // lowercase and trim before returning it - this.response = soapResponse.toLowerCase().trim(); - } - - toJSON() { - this.json = {}; - let $ = cheerio.load(this.response, { xmlMode: true }); - $(this.bodyTagName).children().each((i, el) => { - if (el.children.length > 1) { - console.log('Has more than one child.'); - } - - if (el.children.length === 1) { - // console.log(el.name, el.children[0].data); - this.json[el.name] = el.children[0].data.replace(/\s{2,}/gi, ' ').replace(/\n/gi, '').trim(); - } - }); - - // Unserialise the ENC_PARAMS value - if ('enc_params' in this.json) { - this.json.enc_params = JSON.parse(this.json.enc_params); - } - - // Get the equivalent HTTP CODE to respond with - this.json = _.assignIn(this.extractCode(), this.json); - delete this.json.return_code; - return this.json; - } - - extractCode() { - return _.find(statusCodes, (o) => o.returnCode == this.json.return_code); - } -} diff --git a/server/controllers/payment-confirm.js b/server/controllers/payment-confirm.js deleted file mode 100644 index acb1f70..0000000 --- a/server/controllers/payment-confirm.js +++ /dev/null @@ -1,60 +0,0 @@ -import request from 'request'; -import moment from 'moment'; -import EncryptPassword from './encrypt'; -import ParseResponse from './parse-response'; - - -export default class ConfirmPayment { - static construct(data) { - data.timeStamp = moment().format('YYYYMMDDHHmmss'); // In PHP => "YmdHis" - data.encryptedPassword = new EncryptPassword(data.timeStamp).hashedPassword; - - let transactionConfirmRequest = typeof data.transactionID !== undefined ? - '' + data.transactionID + '' : - '' + data.merchantTransactionID + ''; - - return ` - - - ${process.env.PAYBILL_NUMBER} - ${data.encryptedPassword} - ${data.timeStamp} - - - - - ${transactionConfirmRequest} - - - `; - } - - static send(soapBody) { - return new Promise((resolve, reject) => { - request({ - 'method': 'POST', - 'uri': process.env.ENDPOINT, - 'rejectUnauthorized': false, - 'body': soapBody, - 'headers': { - 'content-type': 'application/xml; charset=utf-8' - } - }, (err, response, body) => { - if (err) { - reject(err); - return; - } - - // console.log('RESPONSE: ', body); - let parsed = new ParseResponse(body, 'transactionconfirmresponse'); - let json = parsed.toJSON(); - - if (json.httpCode !== 200) { - reject(json); - return; - } - resolve(json); - }); - }); - } -} diff --git a/server/controllers/payment-request.js b/server/controllers/payment-request.js deleted file mode 100644 index c664e19..0000000 --- a/server/controllers/payment-request.js +++ /dev/null @@ -1,63 +0,0 @@ -import request from 'request'; -import moment from 'moment'; -import EncryptPassword from './encrypt'; -import ParseResponse from './parse-response'; - - -export default class PaymentRequest { - static construct(data) { - data.timeStamp = moment().format('YYYYMMDDHHmmss'); // In PHP => "YmdHis" - data.encryptedPassword = new EncryptPassword(data.timeStamp).hashedPassword; - - return ` - - - ${process.env.PAYBILL_NUMBER} - ${data.encryptedPassword} - ${data.timeStamp} - - - - - ${data.merchantTransactionID} - ${data.referenceID} - ${data.amountInDoubleFloat} - ${data.clientPhoneNumber} - ${(data.extraMerchantPayload || '')} - ${process.env.CALLBACK_URL} - ${process.env.CALLBACK_METHOD} - ${data.timeStamp} - - - `; - } - - static send(soapBody) { - return new Promise((resolve, reject) => { - request({ - 'method': 'POST', - 'uri': process.env.ENDPOINT, - 'rejectUnauthorized': false, - 'body': soapBody, - 'headers': { - 'content-type': 'application/xml; charset=utf-8' - } - }, (err, response, body) => { - if (err) { - reject(err); - return; - } - - // console.log('RESPONSE: ', body); - let parsed = new ParseResponse(body, 'processcheckoutresponse'); - let json = parsed.toJSON(); - - if (json.httpCode !== 200) { - reject(json); - return; - } - resolve(json); - }); - }); - } -} diff --git a/server/controllers/payment-status.js b/server/controllers/payment-status.js deleted file mode 100644 index 1d984ad..0000000 --- a/server/controllers/payment-status.js +++ /dev/null @@ -1,60 +0,0 @@ -import request from 'request'; -import moment from 'moment'; -import EncryptPassword from './encrypt'; -import ParseResponse from './parse-response'; - - -export default class PaymentStatus { - static construct(data) { - data.timeStamp = moment().format('YYYYMMDDHHmmss'); // In PHP => "YmdHis" - data.encryptedPassword = new EncryptPassword(data.timeStamp).hashedPassword; - - let transactionStatusRequest = typeof data.transactionID !== undefined ? - '' + data.transactionID + '' : - '' + data.merchantTransactionID + ''; - - return ` - - - ${process.env.PAYBILL_NUMBER} - ${data.encryptedPassword} - ${data.timeStamp} - - - - - ${transactionStatusRequest} - - - `; - } - - static send(soapBody) { - return new Promise((resolve, reject) => { - request({ - 'method': 'POST', - 'uri': process.env.ENDPOINT, - 'rejectUnauthorized': false, - 'body': soapBody, - 'headers': { - 'content-type': 'application/xml; charset=utf-8' - } - }, (err, response, body) => { - if (err) { - reject(err); - return; - } - - // console.log('RESPONSE: ', body); - let parsed = new ParseResponse(body, 'transactionstatusresponse'); - let json = parsed.toJSON(); - - if (json.httpCode !== 200) { - reject(json); - return; - } - resolve(json); - }); - }); - } -} diff --git a/server/models/index.js b/server/models/index.js deleted file mode 100644 index c7f0a34..0000000 --- a/server/models/index.js +++ /dev/null @@ -1,21 +0,0 @@ -// instantiate the database connection -import mongoose from '../config/database'; -import path from 'path'; -import fs from 'fs'; -import ucFirst from '../utils/ucfirst'; - - -// load models -const Schema = mongoose.Schema; -fs.readdirSync(__dirname) - .filter(function(file) { - return (file.indexOf('.') !== 0) && (file !== path.basename(module.filename)); - }) - .forEach(function(file) { - if (file.slice(-3) !== '.js') return; - var modelName = file.replace('.js', ''); - module.exports[ucFirst(modelName)] = require(path.join(__dirname, modelName))(mongoose, Schema); - }); - -// export connection -export default { mongoose, Schema }; diff --git a/server/public/css/style.css b/server/public/css/style.css new file mode 100644 index 0000000..e9b9a08 --- /dev/null +++ b/server/public/css/style.css @@ -0,0 +1 @@ +@charset "utf-8";@import url(https://fonts.googleapis.com/css?family=Asap:400,700);html{cursor:default;font-family:'Asap',sans-serif;margin:0;overflow-x:hidden;padding:0}body{cursor:default;font-family:'Asap',sans-serif;font-size:1.5em;margin:0;overflow-x:hidden;padding:0;color:#3a3a3a;background:#fefefe}.homepage{margin-top:10%;text-align:center}.homepage h1{font-weight:bolder;font-size:4em;padding-bottom:0;margin-bottom:0}.homepage p{font-size:2em}.homepage footer{font-size:.25em}.homepage .red{font-weight:bold;color:red} \ No newline at end of file diff --git a/server/public/css/style.less b/server/public/css/style.less new file mode 100644 index 0000000..ddf60b6 --- /dev/null +++ b/server/public/css/style.less @@ -0,0 +1,41 @@ +@import url(https://fonts.googleapis.com/css?family=Asap:400,700); +@charset "utf-8"; +html { + cursor: default; + font-family: 'Asap', sans-serif; + margin: 0; + overflow-x: hidden; + padding: 0; +} + +body { + cursor: default; + font-family: 'Asap', sans-serif; + font-size: 1.5em; + margin: 0; + overflow-x: hidden; + padding: 0; + color: #3a3a3a; + background: #fefefe; +} + +.homepage { + margin-top: 10%; + text-align: center; + h1 { + font-weight: bolder; + font-size: 4em; + padding-bottom: 0; + margin-bottom: 0; + } + p { + font-size: 2em; + } + footer { + font-size: .25em; + } + .red { + font-weight: bold; + color: red; + } +} diff --git a/server/routes/index.js b/server/routes/index.js index 67d9cc8..bac16f6 100644 --- a/server/routes/index.js +++ b/server/routes/index.js @@ -1,49 +1,28 @@ -import uuid from 'node-uuid'; -import ResponseError from '../controllers/errorhandler'; -import PaymentRequest from '../controllers/payment-request'; -import ConfirmPayment from '../controllers/payment-confirm'; -import PaymentStatus from '../controllers/payment-status'; +'use strict'; +const PaymentRequest = require('../controllers/PaymentRequest'); +const ConfirmPayment = require('../controllers/ConfirmPayment'); +const PaymentStatus = require('../controllers/PaymentStatus'); +const PaymentSuccess = require('../controllers/PaymentSuccess'); +const checkForRequiredParams = require('../validators/checkForRequiredParams'); -export default function(router) { - /* Check the status of the API system */ - router.get('/', function(req, res) { - return res.json({ 'status': 200 }); - }); - router.get('/payment/request', function(req, res) { - let request = PaymentRequest.send(PaymentRequest.construct({ - referenceID: uuid.v4(), // product, service or order ID - merchantTransactionID: uuid.v1(), // time-based - amountInDoubleFloat: '10.00', - clientPhoneNumber: '254723001575', - extraMerchantPayload: JSON.stringify({ 'extra': 'info', 'as': 'object' }) - })); +module.exports = (router) => { + // check the status of the API system + router.get('/status', (req, res) => res.json({ status: 200 })); - // process request response - request.then((response) => res.json(response)) - .catch((_error) => ResponseError.handler(_error, res)); - }); + router.post( + '/payment/request', + checkForRequiredParams, + (req, res) => PaymentRequest.handler(req, res) + ); + router.get('/payment/confirm/:id', (req, res) => ConfirmPayment.handler(req, res)); + router.get('/payment/status/:id', (req, res) => PaymentStatus.handler(req, res)); + router.all('/payment/success', (req, res) => PaymentSuccess.handler(req, res)); - router.get('/payment/confirm/:id', function(req, res) { - let confirm = ConfirmPayment.send(ConfirmPayment.construct({ - transactionID: req.params.id // eg. '99d0b1c0237b70f3dc63f36232b9984c' - })); - - // process ConfirmPayment response - confirm.then((response) => res.json(response)) - .catch((_error) => ResponseError.handler(_error, res)); - }); - - router.get('/payment/status/:id', function(req, res) { - let status = PaymentStatus.send(PaymentStatus.construct({ - transactionID: req.params.id // eg. '99d0b1c0237b70f3dc63f36232b9984c' - })); - - // process PaymentStatus response - status.then((response) => res.json(response)) - .catch((_error) => ResponseError.handler(_error, res)); - }); + // for testing last POST response + // if MERCHANT_ENDPOINT has not been provided + router.all('/thumbs/up', (req, res) => res.sendStatus(200)); return router; -} +}; diff --git a/server/controllers/encrypt.js b/server/utils/GenEncryptedPassword.js similarity index 54% rename from server/controllers/encrypt.js rename to server/utils/GenEncryptedPassword.js index e00cb64..e74cb16 100644 --- a/server/controllers/encrypt.js +++ b/server/utils/GenEncryptedPassword.js @@ -1,12 +1,18 @@ -import crypto from 'crypto'; +'use strict'; +const crypto = require('crypto'); -export default class EncryptedPassword { + +module.exports = class GenEncryptedPassword { constructor(timeStamp) { - let concatenatedString = [process.env.PAYBILL_NUMBER, process.env.PASSKEY, timeStamp].join(''); - let hash = crypto.createHash('sha256'); + const concatenatedString = [ + process.env.PAYBILL_NUMBER, + process.env.PASSKEY, + timeStamp, + ].join(''); + const hash = crypto.createHash('sha256'); this.hashedPassword = hash.update(concatenatedString).digest('hex'); // or 'binary' this.hashedPassword = new Buffer(this.hashedPassword).toString('base64'); // this.hashedPassword = this.hashedPassword.toUpperCase(); // console.log('hashedPassword ==> ', this.hashedPassword); } -} +}; diff --git a/server/utils/ParseResponse.js b/server/utils/ParseResponse.js new file mode 100644 index 0000000..86cebe5 --- /dev/null +++ b/server/utils/ParseResponse.js @@ -0,0 +1,63 @@ +'use strict'; +const cheerio = require('cheerio'); +const statusCodes = require('../config/statusCodes'); + + +module.exports = class ParseResponse { + constructor(bodyTagName) { + this.bodyTagName = bodyTagName; + } + + parse(soapResponse) { + const XMLHeader = /<\?[\w\s=.\-'"]+\?>/gi; + const soapHeaderPrefixes = /(<([\w\-]+:[\w\-]+\s)([\w=\-:"'\\\/\.]+\s?)+?>)/gi; + + // Remove the XML header tag + soapResponse = soapResponse.replace(XMLHeader, ''); + + // Get the element PREFIXES from the soap wrapper + const soapInstance = soapResponse.match(soapHeaderPrefixes); + let soapPrefixes = soapInstance[0].match(/((xmlns):[\w\-]+)+/gi); + soapPrefixes = soapPrefixes.map(prefix => prefix.split(':')[1].replace(/\s+/gi, '')); + + // Now clean the SOAP elements in the response + soapPrefixes.forEach(prefix => { + const xmlPrefixes = new RegExp(prefix + ':', 'gmi'); + soapResponse = soapResponse.replace(xmlPrefixes, ''); + }); + + // Remove xmlns from the soap wrapper + soapResponse = soapResponse.replace(/(xmlns):/gmi, ''); + + // lowercase and trim before returning it + this.response = soapResponse.toLowerCase().trim(); + return this; + } + + toJSON() { + this.json = {}; + const $ = cheerio.load(this.response, { xmlMode: true }); + + // Get the children tagName and its values + $(this.bodyTagName).children().each((i, el) => { + // if (el.children.length > 1) return; + if (el.children.length === 1) { + // console.log(el.name, el.children[0].data); + let value = el.children[0].data.replace(/\s{2,}/gi, ' '); + value = value.replace(/\n/gi, '').trim(); + this.json[el.name] = value; + } + }); + + // delete the enc_params value + delete this.json.enc_params; + + // Get the equivalent HTTP CODE to respond with + this.json = Object.assign({}, this.extractCode(), this.json); + return this.json; + } + + extractCode() { + return statusCodes.find(sts => sts.return_code === parseInt(this.json.return_code, 10)); + } +}; diff --git a/server/utils/SOAPRequest.js b/server/utils/SOAPRequest.js new file mode 100644 index 0000000..2a89f3c --- /dev/null +++ b/server/utils/SOAPRequest.js @@ -0,0 +1,45 @@ +'use strict'; + +const request = require('request'); + +module.exports = class SOAPRequest { + construct(payment, parser) { + this.request = request; + this.parser = parser; + this.requestOptions = { + method: 'POST', + uri: process.env.ENDPOINT, + rejectUnauthorized: false, + body: payment.body, + headers: { + 'content-type': 'application/xml; charset=utf-8', + }, + }; + return this; + } + + post() { + return new Promise((resolve, reject) => { + // Make the soap request to the SAG URI + this.request(this.requestOptions, (error, response, body) => { + if (error) { + reject({ description: error.message }); + return; + } + + const parsedResponse = this.parser.parse(body); + const json = parsedResponse.toJSON(); + + // Anything that is not "00" as the + // SOAP response code is a Failure + if (json && json.status_code !== 200) { + reject(json); + return; + } + + // Else everything went well + resolve(json); + }); + }); + } +}; diff --git a/server/utils/errors/responseError.js b/server/utils/errors/responseError.js new file mode 100644 index 0000000..818fdd1 --- /dev/null +++ b/server/utils/errors/responseError.js @@ -0,0 +1,12 @@ +'use strict'; + +const responseError = (error, res) => { + const descriptionExists = (typeof error === 'object' && 'description' in error); + const statusCodeExists = (typeof error === 'object' && 'status_code' in error); + + const err = new Error(descriptionExists ? error.description : error); + err.status = statusCodeExists ? error.status_code : 500; + return res.status(err.status).json({ response: error }); +}; + +module.exports = responseError; diff --git a/server/utils/genTransactionPassword.js b/server/utils/genTransactionPassword.js new file mode 100644 index 0000000..9b70265 --- /dev/null +++ b/server/utils/genTransactionPassword.js @@ -0,0 +1,13 @@ +'use strict'; +const moment = require('moment'); +const GenEncryptedPassword = require('./GenEncryptedPassword'); + + +const genTransactionPassword = (req, res, next) => { + req.timeStamp = moment().format('YYYYMMDDHHmmss'); // In PHP => "YmdHis" + req.encryptedPassword = new GenEncryptedPassword(req.timeStamp).hashedPassword; + // console.log('encryptedPassword:', req.encryptedPassword); + next(); +}; + +module.exports = genTransactionPassword; diff --git a/server/utils/ucFirst.js b/server/utils/ucFirst.js index 5d4665b..a097862 100644 --- a/server/utils/ucFirst.js +++ b/server/utils/ucFirst.js @@ -1,23 +1,25 @@ -// ucFirst (typeof String): returns the String in question but changes the First Character to an Upper case -export default function(string) { - var word = string, - ucFirstWord = ''; +'use strict'; +// ucFirst (typeof String): +// returns String with first character uppercased +module.exports = (string) => { + const word = string; + let ucFirstWord = ''; - for (var x = 0, length = word.length; x < length; x++) { + for (let x = 0, length = word.length; x < length; x++) { // get the character's ASCII code - var character = word[x], + let character = word[x]; // check to see if the character is capitalised/in uppercase using REGEX - isUpperCase = /[A-Z]/g.test(character), - asciiCode = character.charCodeAt(0); + const isUpperCase = /[A-Z]/g.test(character); + const asciiCode = character.charCodeAt(0); - if ((asciiCode >= 65 && asciiCode <= (65 + 25)) || (asciiCode >= 97 && asciiCode <= (97 + 25))) { + if ((asciiCode >= 65 && asciiCode <= (65 + 25)) || + (asciiCode >= 97 && asciiCode <= (97 + 25))) { // If the 1st letter is not in uppercase if (!isUpperCase && x === 0) { // capitalize the letter, then convert it back to decimal value character = String.fromCharCode(asciiCode - 32); - } - // lowercase any of the letters that are not in the 1st postion that are in uppercase - else if (isUpperCase && x > 0) { + } else if (isUpperCase && x > 0) { + // lowercase any of the letters that are not in the 1st postion that are in uppercase // lower case the letter, converting it back to decimal value character = String.fromCharCode(asciiCode + 32); } @@ -25,5 +27,6 @@ export default function(string) { ucFirstWord += character; } + return ucFirstWord; -} +}; diff --git a/server/validators/checkForRequiredParams.js b/server/validators/checkForRequiredParams.js new file mode 100644 index 0000000..70493c6 --- /dev/null +++ b/server/validators/checkForRequiredParams.js @@ -0,0 +1,47 @@ +'use strict'; + +module.exports = (req, res, next) => { + const requiredBodyParams = [ + 'referenceID', + 'merchantTransactionID', + 'totalAmount', + 'phoneNumber', + ]; + + if (req.body && 'phoneNumber' in req.body) { + // validate the phone number + if (!/\+?(254)[0-9]{9}/g.test(req.body.phoneNumber)) { + return res.status(400).send('Invalid [phoneNumber]'); + } + } else { + return res.status(400).send('No [phoneNumber] parameter was found'); + } + + // validate total amount + if (req.body && 'totalAmount' in req.body) { + if (!/^[\d]+(\.[\d]{2})?$/g.test(req.body.totalAmount)) { + return res.status(400).send('Invalid [totalAmount]'); + } + + if (/^[\d]+$/g.test(req.body.totalAmount)) { + req.body.totalAmount = (parseInt(req.body.totalAmount, 10)).toFixed(2); + } + } else { + return res.status(400).send('No [totalAmount] parameter was found'); + } + + const bodyParamKeys = Object.keys(req.body); + const extraPayload = {}; + + // anything that is not a required param + // should be added to the extraPayload object + for (const key of bodyParamKeys) { + if (requiredBodyParams.indexOf(key) === -1) { + extraPayload[key] = req.body[key]; + delete req.body[key]; + } + } + req.body.extraPayload = extraPayload; + // console.log('extraPayload', req.body.extraPayload); + return next(); +}; diff --git a/server/views/index.jade b/server/views/index.jade index 3d63b9a..576f278 100644 --- a/server/views/index.jade +++ b/server/views/index.jade @@ -1,5 +1,11 @@ extends layout block content - h1= title - p Welcome to #{title} + .homepage + a(style="color: #5FAD24" href="https://kn9ts.github.io/project-mulla" target="_blank") + h1= title + p ...because Time is Money! + footer + p.text-center.text-muted.small-padding + | © 2016, with love by + Eugene Mutai diff --git a/server/views/layout.jade b/server/views/layout.jade index 15af079..215cbc6 100644 --- a/server/views/layout.jade +++ b/server/views/layout.jade @@ -2,6 +2,6 @@ doctype html html head title= title - link(rel='stylesheet', href='/stylesheets/style.css') + link(rel='stylesheet', href='/css/style.css') body block content diff --git a/test/controllers/ConfirmPayment.js b/test/controllers/ConfirmPayment.js new file mode 100644 index 0000000..eee7eb0 --- /dev/null +++ b/test/controllers/ConfirmPayment.js @@ -0,0 +1,91 @@ +'use strict'; + +require('../../environment'); +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); +const moment = require('moment'); +const uuid = require('node-uuid'); + +const confirmPayment = require('../../server/controllers/ConfirmPayment'); +const GenEncryptedPassword = require('../../server/utils/GenEncryptedPassword'); + +describe('confirmPayment', () => { + const timeStamp = moment().format('YYYYMMDDHHmmss'); + const encryptedPassword = new GenEncryptedPassword(timeStamp).hashedPassword; + const params = { + transactionID: uuid.v1(), + timeStamp, + encryptedPassword, + }; + + const req = {}; + req.timeStamp = timeStamp; + req.encryptedPassword = encryptedPassword; + req.params = { + id: uuid.v1(), + }; + + const res = {}; + res.status = sinon.stub().returns(res); + res.json = sinon.stub(); + + const response = { status_code: 200 }; + const promise = new Promise((resolve) => { + resolve(response); + }); + + sinon.stub(promise, 'then', (callback) => { + callback(response); + return promise; + }); + + sinon.stub(promise, 'catch', (callback) => { + callback(new Error('threw an error')); + return promise; + }); + + confirmPayment.parser = sinon.stub().returnsThis(); + confirmPayment.soapRequest.construct = sinon.stub().returnsThis(); + confirmPayment.soapRequest.post = sinon.stub().returns(promise); + + it('BuildSoapBody builds the soap body string with transactionID', () => { + confirmPayment.buildSoapBody(params); + + assert.isString(confirmPayment.body); + assert.match(confirmPayment.body, /(TRX_ID)/); + assert.notMatch(confirmPayment.body, /(MERCHANT_TRANSACTION_ID)/); + assert.match(confirmPayment.body, /(soapenv:Envelope)/gi); + }); + + it('if transactionID is not provide soap body built with merchantTransactionID', () => { + delete params.transactionID; + params.merchantTransactionID = uuid.v4(); + confirmPayment.buildSoapBody(params); + + assert.isString(confirmPayment.body); + assert.match(confirmPayment.body, /(MERCHANT_TRANSACTION_ID)/); + assert.notMatch(confirmPayment.body, /(TRX_ID)/); + assert.match(confirmPayment.body, /(soapenv:Envelope)/gi); + }); + + it('Makes a SOAP request and returns a promise', () => { + confirmPayment.buildSoapBody = sinon.stub(); + confirmPayment.handler(req, res); + + assert.isTrue(confirmPayment.buildSoapBody.called); + assert.isTrue(confirmPayment.soapRequest.construct.called); + assert.isTrue(confirmPayment.soapRequest.post.called); + + assert.isTrue(promise.then.called); + assert.isTrue(promise.catch.called); + assert.isTrue(res.status.calledWithExactly(200)); + assert.isTrue(res.json.called); + + const spyCall = res.json.getCall(0); + assert.isObject(spyCall.args[0]); + assert.sameMembers(Object.keys(spyCall.args[0].response), [ + 'status_code', + ]); + }); +}); diff --git a/test/controllers/PaymentRequest.js b/test/controllers/PaymentRequest.js new file mode 100644 index 0000000..f9c72ff --- /dev/null +++ b/test/controllers/PaymentRequest.js @@ -0,0 +1,91 @@ +'use strict'; + +require('../../environment'); +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); +const moment = require('moment'); +const uuid = require('node-uuid'); + +const paymentRequest = require('../../server/controllers/PaymentRequest'); +const GenEncryptedPassword = require('../../server/utils/GenEncryptedPassword'); + +describe('paymentRequest', () => { + const timeStamp = moment().format('YYYYMMDDHHmmss'); + const encryptedPassword = new GenEncryptedPassword(timeStamp).hashedPassword; + const params = { + referenceID: uuid.v4(), + merchantTransactionID: uuid.v1(), + amountInDoubleFloat: '100.00', + clientPhoneNumber: '254723001575', + extraPayload: {}, + timeStamp, + encryptedPassword, + }; + + const req = {}; + req.timeStamp = timeStamp; + req.encryptedPassword = encryptedPassword; + req.body = { + totalAmount: '100.00', + phoneNumber: '254723001575', + extraPayload: {}, + }; + + const res = {}; + res.status = sinon.stub().returns(res); + res.json = sinon.stub(); + + const response = { status_code: 200 }; + const promise = new Promise((resolve) => { + resolve(response); + }); + + sinon.stub(promise, 'then', (callback) => { + callback(response); + return promise; + }); + + sinon.stub(promise, 'catch', (callback) => { + callback(new Error('threw an error')); + return promise; + }); + + paymentRequest.parser = sinon.stub().returnsThis(); + paymentRequest.soapRequest.construct = sinon.stub().returnsThis(); + paymentRequest.soapRequest.post = sinon.stub().returns(promise); + + it('BuildSoapBody builds the soap body string', () => { + paymentRequest.buildSoapBody(params); + + assert.isString(paymentRequest.body); + assert.match(paymentRequest.body, /(soapenv:Envelope)/gi); + }); + + it('Makes a SOAP request and returns a promise', () => { + paymentRequest.buildSoapBody = sinon.stub(); + paymentRequest.handler(req, res); + + assert.isTrue(paymentRequest.buildSoapBody.called); + assert.isTrue(paymentRequest.soapRequest.construct.called); + assert.isTrue(paymentRequest.soapRequest.post.called); + + assert.isTrue(promise.then.called); + assert.isTrue(promise.catch.called); + assert.isTrue(res.status.calledWithExactly(200)); + assert.isTrue(res.json.called); + + const spyCall = res.json.getCall(0); + assert.isObject(spyCall.args[0]); + assert.sameMembers(Object.keys(spyCall.args[0].response), [ + 'status_code', + 'reference_id', + 'merchant_transaction_id', + 'amount_in_double_float', + 'client_phone_number', + 'extra_payload', + 'time_stamp', + 'callback_url', + ]); + }); +}); diff --git a/test/controllers/PaymentStatus.js b/test/controllers/PaymentStatus.js new file mode 100644 index 0000000..7f2a35f --- /dev/null +++ b/test/controllers/PaymentStatus.js @@ -0,0 +1,91 @@ +'use strict'; + +require('../../environment'); +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); +const moment = require('moment'); +const uuid = require('node-uuid'); + +const paymentStatus = require('../../server/controllers/PaymentStatus'); +const GenEncryptedPassword = require('../../server/utils/GenEncryptedPassword'); + +describe('paymentStatus', () => { + const timeStamp = moment().format('YYYYMMDDHHmmss'); + const encryptedPassword = new GenEncryptedPassword(timeStamp).hashedPassword; + const params = { + transactionID: uuid.v1(), + timeStamp, + encryptedPassword, + }; + + const req = {}; + req.timeStamp = timeStamp; + req.encryptedPassword = encryptedPassword; + req.params = { + id: uuid.v1(), + }; + + const res = {}; + res.status = sinon.stub().returns(res); + res.json = sinon.stub(); + + const response = { status_code: 200 }; + const promise = new Promise((resolve) => { + resolve(response); + }); + + sinon.stub(promise, 'then', (callback) => { + callback(response); + return promise; + }); + + sinon.stub(promise, 'catch', (callback) => { + callback(new Error('threw an error')); + return promise; + }); + + paymentStatus.parser = sinon.stub().returnsThis(); + paymentStatus.soapRequest.construct = sinon.stub().returnsThis(); + paymentStatus.soapRequest.post = sinon.stub().returns(promise); + + it('BuildSoapBody builds the soap body string with transactionID', () => { + paymentStatus.buildSoapBody(params); + + assert.isString(paymentStatus.body); + assert.match(paymentStatus.body, /(TRX_ID)/); + assert.notMatch(paymentStatus.body, /(MERCHANT_TRANSACTION_ID)/); + assert.match(paymentStatus.body, /(soapenv:Envelope)/gi); + }); + + it('if transactionID is not provide soap body built with merchantTransactionID', () => { + delete params.transactionID; + params.merchantTransactionID = uuid.v4(); + paymentStatus.buildSoapBody(params); + + assert.isString(paymentStatus.body); + assert.match(paymentStatus.body, /(MERCHANT_TRANSACTION_ID)/); + assert.notMatch(paymentStatus.body, /(TRX_ID)/); + assert.match(paymentStatus.body, /(soapenv:Envelope)/gi); + }); + + it('Makes a SOAP request and returns a promise', () => { + paymentStatus.buildSoapBody = sinon.stub(); + paymentStatus.handler(req, res); + + assert.isTrue(paymentStatus.buildSoapBody.called); + assert.isTrue(paymentStatus.soapRequest.construct.called); + assert.isTrue(paymentStatus.soapRequest.post.called); + + assert.isTrue(promise.then.called); + assert.isTrue(promise.catch.called); + assert.isTrue(res.status.calledWithExactly(200)); + assert.isTrue(res.json.called); + + const spyCall = res.json.getCall(0); + assert.isObject(spyCall.args[0]); + assert.sameMembers(Object.keys(spyCall.args[0].response), [ + 'status_code', + ]); + }); +}); diff --git a/test/controllers/PaymentSuccess.js b/test/controllers/PaymentSuccess.js new file mode 100644 index 0000000..bf3e1a6 --- /dev/null +++ b/test/controllers/PaymentSuccess.js @@ -0,0 +1,77 @@ +'use strict'; + +require('../../environment'); +const chai = require('chai'); +const assert = chai.assert; +const expect = chai.expect; +const sinon = require('sinon'); + +const paymentSuccess = require('../../server/controllers/PaymentSuccess'); + +describe('paymentSuccess', () => { + const req = {}; + req.protocol = 'https'; + req.hostname = 'localhost'; + req.body = { + MSISDN: '254723001575', + MERCHANT_TRANSACTION_ID: 'FG232FT0', + USERNAME: '', + PASSWORD: '', + AMOUNT: '100', + TRX_STATUS: 'Success', + RETURN_CODE: '00', + DESCRIPTION: 'Transaction successful', + 'M-PESA_TRX_DATE': '2014-08-01 15:30:00', + 'M-PESA_TRX_ID': 'FG232FT0', + TRX_ID: '1448', + ENC_PARAMS: '{}', + }; + const res = {}; + res.sendStatus = sinon.stub(); + const next = sinon.stub(); + + const response = {}; + for (const x of Object.keys(req.body)) { + const prop = x.toLowerCase().replace(/\-/g, ''); + response[prop] = req.body[x]; + } + + let error = false; + sinon.stub(paymentSuccess, 'request', (params, callback) => { + callback(error); + }); + + it('Make a request to MERCHANT_ENDPOINT and respond to SAG with OK', () => { + process.env.MERCHANT_ENDPOINT = 'https://awesome-service.com/mpesa/callback'; + paymentSuccess.handler(req, res, next); + + const spyCall = paymentSuccess.request.getCall(0); + const args = spyCall.args[0]; + + assert.isTrue(res.sendStatus.calledWithExactly(200)); + assert.isTrue(paymentSuccess.request.called); + assert.isFalse(next.called); + expect(response).to.deep.equal(JSON.parse(args.body)); + }); + + it('If MERCHANT_ENDPOINT is not provided, next is passed an error', () => { + delete process.env.MERCHANT_ENDPOINT; + process.env.NODE_ENV = 'production'; + paymentSuccess.handler(req, res, next); + + console.log(next.args); + const spyCall = next.getCall(0); + const args = spyCall.args[0]; + + assert.isTrue(next.called); + assert.isTrue(args instanceof Error); + }); + + it('If ENDPOINT is not reachable, an error reponse is sent back', () => { + process.env.MERCHANT_ENDPOINT = 'https://undefined-url'; + error = new Error('ENDPOINT not reachable'); + paymentSuccess.handler(req, res, next); + + assert.isTrue(res.sendStatus.calledWithExactly(500)); + }); +}); diff --git a/test/environment.js b/test/environment.js new file mode 100644 index 0000000..810611d --- /dev/null +++ b/test/environment.js @@ -0,0 +1,19 @@ +'use strict'; + +require('../environment'); +const chai = require('chai'); +const expect = chai.expect; + +describe('environment.js', () => { + it('Should load default environment vars if environment stage is not defined', () => { + // console.log(Object.keys(process.env)); + expect(Object.keys(process.env)).to.include.members([ + 'API_VERSION', + 'ENDPOINT', + 'SESSION_SECRET_KEY', + 'PAYBILL_NUMBER', + 'PASSKEY', + 'MERCHANT_ENDPOINT', + ]); + }); +}); diff --git a/test/utils/ParseResponse.js b/test/utils/ParseResponse.js new file mode 100644 index 0000000..a2c7e01 --- /dev/null +++ b/test/utils/ParseResponse.js @@ -0,0 +1,92 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const chai = require('chai'); +const assert = chai.assert; + +const ParseResponse = require('../../server/utils/ParseResponse'); +const XMLFile = { + processCheckoutResponse: '../../docs/responses/2-process-checkout-response.xml', + transactionConfirmResponse: '../../docs/responses/4-transaction-confirmed.xml', + transactionCompleteResponse: '../../docs/responses/5-transaction-completed.xml', +}; + +describe('ParseResponse', () => { + it('when class is instantiated bodyTagName is defined', () => { + const parser = new ParseResponse('bodyTagName'); + assert.equal(parser.bodyTagName, 'bodyTagName'); + }); + + it('parses a processCheckoutResponse XML response', () => { + const processCheckoutResponse = fs.readFileSync( + path.join(__dirname, XMLFile.processCheckoutResponse), + 'utf-8' + ).replace(/\n\s+/gmi, ''); + + const parser = new ParseResponse('processcheckoutresponse'); + const parsedResponse = parser.parse(processCheckoutResponse); + parsedResponse.toJSON(); + + assert.isString(parser.response, 'XML was parsed'); + assert.notMatch(parser.response, /[A-Z]/gm, 'all character are in lowercase'); + + assert.isObject(parser.json, 'JSON was extracted'); + assert.includeMembers(Object.keys(parser.json), [ + 'return_code', + 'description', + 'trx_id', + 'cust_msg', + ], 'parsed json has the following properties'); + }); + + it('parses a transactionConfirmResponse XML response', () => { + const transactionConfirmResponse = fs.readFileSync( + path.join(__dirname, XMLFile.transactionConfirmResponse), + 'utf-8' + ).replace(/\n\s+/gmi, ''); + + const parser = new ParseResponse('transactionconfirmresponse'); + const parsedResponse = parser.parse(transactionConfirmResponse); + parsedResponse.toJSON(); + + assert.isString(parser.response, 'XML was parsed'); + assert.notMatch(parser.response, /[A-Z]/gm, 'all character are in lowercase'); + + assert.isObject(parser.json, 'JSON was extracted'); + assert.includeMembers(Object.keys(parser.json), [ + 'return_code', + 'description', + 'trx_id', + ], 'parsed json has the following properties'); + }); + + it('parses a transactionCompleteResponse XML response', () => { + const transactionCompleteResponse = fs.readFileSync( + path.join(__dirname, XMLFile.transactionCompleteResponse), + 'utf-8' + ).replace(/\n\s+/gmi, ''); + + const parser = new ParseResponse('resultmsg'); + const parsedResponse = parser.parse(transactionCompleteResponse); + parsedResponse.toJSON(); + + assert.isString(parser.response, 'XML was parsed'); + assert.notMatch(parser.response, /[A-Z]/gm, 'all character are in lowercase'); + + assert.isObject(parser.json, 'JSON was extracted'); + assert.sameMembers(Object.keys(parser.json), [ + 'return_code', + 'status_code', + 'message', + 'msisdn', + 'amount', + 'm-pesa_trx_date', + 'm-pesa_trx_id', + 'trx_status', + 'description', + 'merchant_transaction_id', + 'trx_id', + ], 'parsed json has the following properties'); + }); +}); diff --git a/test/utils/SOAPRequest.js b/test/utils/SOAPRequest.js new file mode 100644 index 0000000..3e13bc6 --- /dev/null +++ b/test/utils/SOAPRequest.js @@ -0,0 +1,96 @@ +'use strict'; + +require('../../environment'); +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); +const moment = require('moment'); +const uuid = require('node-uuid'); + +const SOAPRequest = require('../../server/utils/SOAPRequest'); +const ParseResponse = require('../../server/utils/ParseResponse'); +const paymentRequest = require('../../server/controllers/PaymentRequest'); +const GenEncryptedPassword = require('../../server/utils/GenEncryptedPassword'); + +describe('SOAPRequest', () => { + const timeStamp = moment().format('YYYYMMDDHHmmss'); + const encryptedPassword = new GenEncryptedPassword(timeStamp).hashedPassword; + const paymentDetails = { + referenceID: uuid.v4(), + merchantTransactionID: uuid.v1(), + amountInDoubleFloat: '100.00', + clientPhoneNumber: '254723001575', + extraPayload: {}, + timeStamp, + encryptedPassword, + }; + + const parser = new ParseResponse('bodyTagName'); + parser.parse = sinon.stub().returns(parser); + parser.toJSON = sinon.stub(); + parser.toJSON.onFirstCall().returns({ status_code: 200 }); + parser.toJSON.onSecondCall().returns({ status_code: 400 }); + + const soapRequest = new SOAPRequest(); + paymentRequest.buildSoapBody(paymentDetails); + soapRequest.construct(paymentRequest, parser); + + let requestError = undefined; + sinon.stub(soapRequest, 'request', (params, callback) => { + callback(requestError, null, 'a soap dom tree string'); + }); + + afterEach(() => { + requestError = undefined; + }); + + it('SOAPRequest is contructed', () => { + assert.instanceOf(soapRequest.parser, ParseResponse); + assert.sameMembers(Object.keys(soapRequest.requestOptions), [ + 'method', + 'uri', + 'rejectUnauthorized', + 'body', + 'headers', + ]); + }); + + it('Invokes then method from a successful response', (done) => { + const request = soapRequest.post().then((response) => { + assert.instanceOf(request, Promise); + assert.isObject(response); + assert.sameMembers(Object.keys(response), ['status_code']); + assert.isTrue(soapRequest.parser.parse.called); + assert.isTrue(soapRequest.parser.toJSON.called); + assert.isTrue(soapRequest.request.called); + done(); + }); + }); + + it('Invokes catch method from an unsuccessful response', (done) => { + const request = soapRequest.post().catch((error) => { + assert.instanceOf(request, Promise); + assert.isObject(error); + assert.sameMembers(Object.keys(error), ['status_code']); + assert.isTrue(soapRequest.parser.parse.called); + assert.isTrue(soapRequest.parser.toJSON.called); + assert.isTrue(soapRequest.request.called); + done(); + }); + }); + + + it('Invokes catch method if an error is returned on invalid request', (done) => { + requestError = new Error('invalid URI provided'); + + const request = soapRequest.post().catch((error) => { + assert.instanceOf(request, Promise); + assert.isObject(error); + assert.sameMembers(Object.keys(error), ['description']); + assert.isTrue(soapRequest.parser.parse.called); + assert.isTrue(soapRequest.parser.toJSON.called); + assert.isTrue(soapRequest.request.called); + done(); + }); + }); +}); diff --git a/test/utils/errors/reponseError.js b/test/utils/errors/reponseError.js new file mode 100644 index 0000000..7df4785 --- /dev/null +++ b/test/utils/errors/reponseError.js @@ -0,0 +1,44 @@ +'use strict'; + +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); + +const responseError = require('../../../server/utils/errors/responseError'); + +describe('responseError', () => { + let spyCall; + const res = {}; + let error = 'An error message'; + + beforeEach(() => { + res.status = sinon.stub().returns(res); + res.json = sinon.stub(); + + responseError(error, res); + }); + + it('Calls response method with default(500) error code', () => { + spyCall = res.status.getCall(0); + assert.isTrue(res.status.calledOnce); + assert.isTrue(spyCall.calledWithExactly(500)); + }); + + it('Returns error wrapped in json response', () => { + spyCall = res.json.getCall(0); + assert.isTrue(res.json.calledOnce); + assert.isObject(spyCall.args[0]); + assert.property(spyCall.args[0], 'response', 'status'); + }); + + it('Calls response method with custom error code', () => { + error = { + description: 'Bad request', + status_code: 400, + }; + responseError(error, res); + spyCall = res.status.getCall(0); + assert.isTrue(res.status.called); + assert.isTrue(res.status.calledWithExactly(400)); + }); +}); diff --git a/test/utils/genTransactionPassword.js b/test/utils/genTransactionPassword.js new file mode 100644 index 0000000..ef86e96 --- /dev/null +++ b/test/utils/genTransactionPassword.js @@ -0,0 +1,30 @@ +'use strict'; + +const chai = require('chai'); +const assert = chai.assert; +const expect = chai.expect; +const sinon = require('sinon'); + +const genTransactionPassword = require('../../server/utils/genTransactionPassword'); + +describe('genTransactionPassword', () => { + const req = {}; + const next = sinon.spy(); + + before(() => { + genTransactionPassword(req, null, next); + }); + + it('attaches an encryptedPassword property in request object', () => { + assert.isDefined(req.encryptedPassword, 'encryptedPassword generated'); + }); + + it('attaches a timeStamp property in request object', () => { + assert.isNumber(parseInt(req.timeStamp, 10), 'is numerical'); + assert.lengthOf(req.timeStamp, 14, 'is 14 numbers long'); + }); + + it('expect next to have been called', () => { + expect(next).to.have.been.calledOnce; + }); +}); diff --git a/test/utils/ucFirst.js b/test/utils/ucFirst.js new file mode 100644 index 0000000..4464525 --- /dev/null +++ b/test/utils/ucFirst.js @@ -0,0 +1,18 @@ +'use strict'; + +const chai = require('chai'); +const assert = chai.assert; + +const ucFirst = require('../../server/utils/ucFirst'); + +describe('ucFirst', () => { + it('uppercases the 1st letter in the string', () => { + const string = 'Projectmulla'; + assert.equal(ucFirst(string), 'Projectmulla'); + }); + + it('lowercases all the letters in the string after the 1st letter', () => { + const string = 'proJECTMulLA'; + assert.equal(ucFirst(string), 'Projectmulla'); + }); +}); diff --git a/test/validators/checkForRequiredParams.js b/test/validators/checkForRequiredParams.js new file mode 100644 index 0000000..f1af80d --- /dev/null +++ b/test/validators/checkForRequiredParams.js @@ -0,0 +1,102 @@ +'use strict'; + +const chai = require('chai'); +const assert = chai.assert; +const sinon = require('sinon'); + +const checkForRequiredParams = require('../../server/validators/checkForRequiredParams'); + +describe('checkForRequiredParams', () => { + const res = {}; + const req = {}; + let next = sinon.stub(); + + beforeEach(() => { + res.status = sinon.stub().returns(res); + res.send = sinon.stub(); + next = sinon.stub(); + }); + + it('Throws an error if phone number is not provided', () => { + req.body = {}; + checkForRequiredParams(req, res, next); + + assert.isTrue(res.status.calledWithExactly(400)); + assert.isTrue(res.send.calledOnce); + }); + + it('Throws an error if phone number is not valid', () => { + req.body = { + phoneNumber: '0723001575', + }; + checkForRequiredParams(req, res, next); + const spyCall = res.send.getCall(0); + + assert.isTrue(res.status.calledWithExactly(400)); + assert.isTrue(res.send.calledOnce); + assert.isString(spyCall.args[0], 'called with string'); + }); + + it('Throws an error if total amount is not provided', () => { + req.body = { + phoneNumber: '254723001575', + }; + checkForRequiredParams(req, res, next); + const spyCall = res.send.getCall(0); + + assert.isTrue(res.status.calledWithExactly(400)); + assert.isTrue(res.send.calledOnce); + assert.isString(spyCall.args[0], 'called with string'); + }); + + it('Throws an error if total amount is not provided', () => { + req.body = { + phoneNumber: '254723001575', + totalAmount: 'a hundred bob', + }; + checkForRequiredParams(req, res, next); + const spyCall = res.send.getCall(0); + + assert.isTrue(res.status.calledWithExactly(400)); + assert.isTrue(res.send.calledOnce); + assert.isString(spyCall.args[0], 'called with string'); + }); + + it('Converts a whole number into a number with double floating points', () => { + req.body = { + phoneNumber: '254723001575', + totalAmount: '100', + }; + checkForRequiredParams(req, res, next); + + assert.equal(res.status.callCount, 0); + assert.equal(res.send.callCount, 0); + assert.isNumber(parseInt(req.body.totalAmount, 100), 'should be 100.00'); + }); + + it('Next is returned if everything is valid', () => { + req.body = { + phoneNumber: '254723001575', + totalAmount: '100.00', + }; + checkForRequiredParams(req, res, next); + + assert.equal(res.status.callCount, 0); + assert.equal(res.send.callCount, 0); + assert.isTrue(next.calledOnce); + assert.isDefined(req.body.extraPayload); + }); + + it('Other params are moved into extraPayload property', () => { + req.body = { + phoneNumber: '254723001575', + totalAmount: '100.00', + userID: 1515, + location: 'Kilimani', + }; + checkForRequiredParams(req, res, next); + + assert.isDefined(req.body.extraPayload); + assert.sameMembers(Object.keys(req.body.extraPayload), ['userID', 'location']); + }); +});