
Accept Online Payments In Node.js Using Pesepay
Given Ncube
So earlier this year I published a post on how to integrate Paynow in to your application using a simple API that I came up with. If you're a developer in Zimbabwe you know how much of a pain Paynow is.
The good news is, there is an alternative, Pesepay
What is Pesepay
Pesepay is another payment gateway in Zimbabwe just like Paynow and they brand themselves as a platform that provides "Robust & Secure online payments solution for Africa". What sets Pesepay apart from other solutions is that they provide a JSON REST API that you can actually use!
Can you imagine? An API that you and I and anyone can use?
Anyways, enough excitement, let's get start
A few things to note though
- For ZWL payments, they currently only accept Ecocash
- For USD payments they only take VISA and Ecocash USD
- There's something called CABS payment for ZWL Zimswitch enabled cards but I couldn't figure out it works so I just ignored it
- There's also Paygo QR codes but I don't know how that works either
In this post we will focus on accepting ZWL and USD payments with VISA and Ecocash
Use case
I was recently consulted to add payments on an ecommerce website written in Node so I thought I could share how I implemented it
So let's say you just built your ecommerce backend, store or whatever you have everything else working except the payments.
Our example project is an express.js app with mongo db
Models
So first we need to structure our data to be able to accept payments for orders and such. I'm going to assume you know to structure an express application and mongoose
So first create a model called models/Order.js
in that file paste the following snippet
const mongoose = require('mongoose');
exports.Order = mongoose.model('Order', new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User',
required: true
},
number: {
type: String,
required: true
},
items: [{
type: mongoose.Schema.Types.ObjectId,
ref: 'OrderItem',
required: true
}],
name: {
type: String,
required: true
},
email: {
type: String
},
address: {
type: String,
required: true
},
address1: {
type: String,
default: ''
},
city: {
type: String,
required: true
},
country: {
type: String,
required: true
},
phone: {
type: String,
default: ''
},
status: {
type: String,
required: true,
default: 'pending'
},
total: {
type: Number,
default: 0
},
payment: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Payment'
}
}, {
timestamps: true
}));
So this project was using CommonJS, I couldn't migrate the whole project to ES modules, but you can easily convert this model to es6 if you prefer.
So this model has things the name, country, phone, address, etc for shipping information
There's a relationship to
-
User
we would want to know the user who made the order, -
OrderItems
the actual items that where ordered -
Payment
the model with payment information about this order
Let's add the OrderItem model, I'm sure you know how to create a model, no?
const mongoose = require('mongoose')
exports.OrderItem = mongoose.model('OrderItem', new mongoose.Schema({
product: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Product',
required: true
},
order: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Order',
required: true
},
attributes: {
type: Object,
default: {}
},
unitPrice: {
type: Number,
required: true
},
amount: {
type: Number,
required: true
},
quantity: {
type: Number,
default: 1
}
}, {
timestamps: true
}));
So this one has the relationship to product which we have in our ecommerce app, the attributes like color, size, etc from the shopping cart
Finally let's add the payment model
const mongoose = require('mongoose');
exports.Payment = mongoose.model('Payment', new mongoose.Schema({
user: {
type: mongoose.Schema.Types.ObjectId,
ref: 'User'
},
order: {
type: mongoose.Schema.Types.ObjectId,
ref: 'Order'
},
paid_amount: {
type: Number,
},
amount: {
type: Number,
required: true
},
currency: {
type: String,
required: true
},
status: {
type: String,
default: 'pending'
},
method: {
type: String,
required: true
},
reference: {
type: String,
required: true
}
},{
timestapes: true
}));
Here's an explanation of what each field is for
-
user
is the user that made the payment -
order
the order that this payment is for -
paid_amount
the total amount the customer paid including the charges and what not -
amount
the actual amount that went into our bank account -
currency
the currency that was used to make the payment -
status
the status of the payment upstream(at pesepay) -
method
the payment method used, ecocash, VISA? -
reference
the payment reference id that matches the reference ID upstream
So far so good? OK now let's allow the customer to make an order
Controllers and routes
So this is an express application, in your controllers folder create a file called OrderController.js
and paste the following code
const { Order } = require('../models/Order')
const { OrderItem } = require('../models/OrderItem')
const Product = require('../models/Product')
exports.OrderController = new class {
async store(req, res, next) {
const { products, address, address2, city, country, phone, email, name } = req.body
const order = await Order.create({
user: req.user._id,
address,
address2,
city,
country,
phone,
email,
name,
number: 'ORDR-' + Math.floor(Math.random() * 100000)
})
await Promise.all(products.map(async item => {
const product = await Product.findById(item.product_id)
product.stock -= item.quantity
product.orders.push(order._id)
await product.save()
const orderItem = new OrderItem({
product: product._id,
quantity: item.quantity,
unitPrice: product.price,
order: order._id,
amount: item.quantity * product.price,
attributes: item.attributes
})
await orderItem.save()
order.items.push(orderItem)
order.total += orderItem.amount
}))
await order.save()
return res.status(201).json({
status: 'success',
message: 'Order created successfully',
data: {
order
}
})
}
async show(req, res, next) {
const order = await Order.findById(req.params.id).populate('items')
if (!order) {
return res.status(404).json({
status: 'error',
message: 'Order not found'
})
}
return res.status(200).json({
status: 'success',
data: {
order
}
})
}
async index(req, res, next) {
const orders = await Order.find({ user: req.user._id }).populate('items')
return res.status(200).json({
status: 'success',
data: {
orders
}
})
}
async update(req, res, next) {
const order = await Order.findById(req.params.id)
if (!order) {
return res.status(404).json({
status: 'error',
message: 'Order not found'
})
}
order.status = req.body.status
await order.save()
return res.status(200).json({
status: 'success',
message: 'Order updated successfully',
data: {
order
}
})
}
async destroy(req, res, next) {
const order = await Order.findById(req.params.id)
if (!order) {
return res.status(404).json({
status: 'error',
message: 'Order not found'
})
}
await order.remove()
return res.status(200).json({
status: 'success',
message: 'Order deleted successfully'
})
}
async destroyAll(req, res, next) {
await Order.deleteMany({ user: req.user._id })
return res.status(200).json({
status: 'success',
message: 'Orders deleted successfully'
})
}
}
Notice we export a class instance of the controller from this file, I'm not a fan of function based controllers, but hey, that's just me, if that's your thing you can simply rewrite this controller to your flavour
I'm also a fan of resourceful routing, (Look it up!) so this class has, store()
,update()
,destroy()
,index()
,show()
methods which map to CRUD actions, so let's go over what each method deos
Index()
This returns a list of all orders for the logged in user
Show()
This returns a specific order whose id was passed as a parameter in the URL
Store()
Store persists the model in the database. In our case, we receive order info like name, email, etc and the products
array which would come from the shopping cart
We store the Order in the database then loop through the products
from the cart to create order items and associate them with the order
We save and return the recently created order with a 201 Created
response
Destroy()
You may have guessed this one, it removes the model from the database
Next let's define the routes for orders, in your routes file, add the following snippet
router.get('/orders', auth, OrderController.index)
router.get('/orders/:order', auth, OrderController.show)
router.patch('/orders/:order', auth, OrderController.update)
router.delete('/orders/:order', auth, OrderController.destroy)
router.post('/orders', auth, OrderController.store)
Where auth
is your authentication middleware
If you hit these routes you should be able to create an order, next let's add payments
Adding payments
The first is to head over to pesepay.com and create an account. After your account is create it will be in test mode with a sandbox account which you can use to test your payments
Next, in the dashboard, select My business from the sidebar and submit the required docs for verification, it takes less than an hour to verify it's really fast and easy
After you are done head over to 'Applications' and create a new application after it's done, copy the integration id and encryption key to your .env
file
The next thing we want to do is to create a configuration file. If you handle your configuration differently you can go ahead and do you, but I found this method cleaner and easier to work with
create a file called config/index.js
in the file paste the following code
module.exports = {
redis:{
host: process.env.REDIS_HOST,
port: process.env.REDIS_PORT ,
},
mongo: {
url: process.env.MONGO_URL
},
pesepay: {
integration_id: process.env.PESEPAY_INTEGRATION_ID,
encryption_key: process.env.PESEPAY_ENCRYPTION_KEY,
mode: process.env.PESEPAY_MODE || 'sandbox',
},
}
Now when we want any configuration variable we simply
const { redis } = require('../config')
console.log(redis.host)
Next, we need a library to interact with API, from the documentation, there's an official library which you can install by
yarn add pesepay
However, when I tried it, it didn't give me the results that I wanted and it was last updated like a year or 2 ago. There's an unofficial package by Michael Nyamande called pesepay-js which I recommend you use
Install the package with
yarn add pesepay-js
After the package is installed let's initialize it. So in this app we might need to use pesepay a lot and doesn't make sense to keep initializing it everywhere. We will create a singleton that returns the same instance of the client throughout the app's lifetime
create a file called composables/index.js
and paste the following code
const { pesepay } = require('../config')
const { PesePayClient } = require('pesepay-js');
module.exports = (() => {
let instance = null;
return {
usePesePay: () => {
if (!instance) {
instance = new PesePayClient(pesepay.integration_id, pesepay.encryption_key)
}
return instance;
}
}
})()
So this returns an instance of PesePay client which is initialized with keys
All the basics are set, let's create the payment controller that will be used to make a payment
const { Order } = require('../models/Order')
const { Payment } = require('../models/Payment')
const { usePesePay } = require('../composables')
const paymentMethods = {
ECOCASH: 'PZW201',
VISA: 'PZW204',
ECOCASH_USD: 'PZW211'
}
exports.PaymentController = new class {
/**
* Persist a payment in storage
*/
async store(req, res, next) {
const client = usePesePay()
const order = await Order.findOne({ _id: req.params.order })
if (!order) {
return res.status(404).json({ error: 'Order not found' })
}
const { currency, payment_details } = req.body
const paymentMethodRequiredFields = paymentMethods[payment_details.method.toUpperCase()] === paymentMethods.ECOCASH ? {
customerPhoneNumber: payment_details.phone
} : {
creditCardHolder: payment_details.card_holder,
creditCardExpiryDate: payment_details.card_expiry,
creditCardNumber: payment_details.card_number,
creditCardSecurityNumber: payment_details.cvv
}
const response = await client.makeSeamlessPayment({
amountDetails: {
amount: order.total,
currencyCode: currency
},
merchantReference: order.number + '-' + Date.now(),
reasonForPayment: 'Payment for order ' + order.number ,
resultUrl: "resultUrl",
returnUrl: "returnUrl",
paymentMethodCode: paymentMethods[currency == 'USD' && payment_details.method.toUpperCase() == 'ECOCASH' ? 'ECOCASH_USD' : payment_details.method.toUpperCase()],
customer: {
phoneNumber: order.phone,
},
paymentMethodRequiredFields
})
.then(async response => {
const _payment = new Payment({
order: order._id,
user: req.user._id,
method: payment_details.method,
amount: response.amountDetails.merchantAmount,
paid_amount: response.amountDetails.amount,
reference: response.referenceNumber || response.dateOfTransaction,
status: response.transactionStatus,
currency: response.amountDetails.currencyCode,
})
await _payment.save()
order.status = 'paid'
order.payment = _payment._id
await order.save()
return res.status(200).json({ message: 'Payment submitted for processing', data: { payment: _payment } })
})
.catch(error => {
console.log(error)
return res.status(400).json({ error: 'Payment failed' })
});
return response
}
/**
* Show the payment
* @param {reqeust} req
* @param {response} res
* @returns response
*/
async show(req, res) {
const payment = await Payment.findOne({ _id: req.params.payment })
return res.status(200).json({
data: { payment }
})
}
}
So here the client sends a payload like this
var data = JSON.stringify({
"currency": "USD",
"payment_details": {
"phone": "0786801704",
"method": "ECOCASH"
}
});
If the payment method was a VISA card it would look something like this
{
"currency": "ZWL",
"payment_details": {
"phone": "0786801704",
"method": "VISA",
"card_number": "405405405405430",
"cvv": "708",
"card_holder": "G Ncube",
"card_expiry": "03/26"
}
}
So we get the pese pay client from the factory first, get the order the use wants to pay for from the request, set the required fields depending on the payment method provided
The information in the paymentMethods vairable can be obtained by calling getPaymentMethodsByCurrency(currency)
on the pese pay client
We then call the makeSeamlessPayment
method on the client to initiate a seamless payment, meaning the user doesn't need to leave our website or app to make a payment
That method returns a promise but we await
it to get the final response.
In the then method we create a payment with the information recieved from the gateway.
Finally we return the created payment in the response
So what happened now is that the payment was submitted for processing upstream, this when the user is given a popup to enter PIN or VISA does it's magic with your bank to process the payment
This means we will have to check again later to see if the payment was successful or not, we will get to that shortly
Now let's add the routes for the payment controller
router.post('/orders/:order/payments', auth, PaymentController.store)
router.get('/orders/:order/payments/:payment', PaymentController.show)
Right now if you submit a request to this route using the above payloads you should have pending payment in your database
Polling for transaction status
In your frontend you might keep checking the payments show route to see if the status has changed. To change the status the idea is to use scheduled tasks
In express you have to do this from scratch using packages like bull
, agenda
toad-schedular
, etc, In AdonisJs, Laravel, Rails and other frameworks Queue processing is a first class citizen
But hey, we developers after all. Let's add some poor man's queue processing
So the idea is that we have a job/task that runs every 20 seconds and checks for pending payments to see if they're successful or not
For this we will use a package called agenda
Install it with
yarn add @hokify/agenda
After it's intalled create a file called jobs/index.js
and in that file paste the following code
//jobs/index.js
const {Agenda} = require('@hokify/agenda');
const { mongo } = require(__dirname + "/../config");
const { allDefinitions } = require("./definitions");
const agenda = new Agenda({
db: {
address: mongo.url,
collection: 'agenda_jobs',
options: { useUnifiedTopology: true },
},
processEvery: "30 seconds",
maxConcurrency: 20,
defaultLockLifetime: 2000,
});
allDefinitions(agenda);
agenda.start()
agenda
.on('ready', () => console.log("Agenda started!"))
.on('error', (err) => console.log("Agenda connection error!", err));
module.exports = {agenda};
This simply initializes agenda, calls the all definitions method below to create agenda definitions and exports it.
Create another file called jobs/definitions/index.js
and paste the following code
//jobs/definitions/index.js
const { pendingPayments } = require('./payments');
const definitions = [pendingPayments];
const allDefinitions = (agenda) => {
definitions.forEach((definition) => definition(agenda));
};
module.exports = { allDefinitions }
This goes through all defined jobs and register them with agenda
Create another one called jobs/definitions/payments.js
and add the following code
const { JobHandlers } = require("../handlers");
module.exports ={
pendingPayments: (agenda) => {
agenda.define('update-pending-payments', JobHandlers.PendingPayments)
}
}
The actual job definition
Let's create another file for job handlers called jobs/handlers/index.js
and paste the following code
//jobs/handlers/index.js
const PendingPayments = require('./PendingPayments')
const JobHandlers = {
PendingPayments,
}
module.exports = { JobHandlers }
Let's create the job handler to update pending payments in a file called jobs/handlers/PendingPayments.js
const { usePesePay } = require("../../composables");
const { Payment } = require("../../models/Payment");
module.exports = async (job, done) => {
const client = usePesePay();
const payments = await Payment.find({ $or: [{ status: 'PENDING' }, { status: 'PROCESSING' }] })
payments.forEach(async (payment) => {
client.checkPaymentStatus(payment.reference).then(async (response) => {
await Payment.updateOne({ _id: payment._id }, { status: response.transactionStatus })
}).catch(error => {
console.log(error)
})
})
job.repeatAt('in 20 seconds')
await job.save()
done()
}
This is the acutal job handler which loops through the payment models which have a pending or processing status,
polls upstream for payment status by calling the checkPaymentStatus()
method of the pesepay client, after getting a response we update the status from the gateway repeat again after 20 seconds
Now we need a schedular to to run this job
Create a file called jobs/schedular.js
and add the following snippet
const { agenda } = require('./index')
const schedule = {
updatePendingPayments: async (data) => {
await agenda.now('update-pending-payments', data)
},
// .... more methods that shedule tasks at the different intervals.
}
module.exports = { schedule }
Finally, let's run the schedular, from your index.js file or what your entry point is, add the following snippet
const { schedule } = require('./jobs/schedular')
schedule.updatePendingPayments()
So this will keep checking for pending payments and update the status. Now restart your server, create an order, and make a payment you should be able to the results
A note on sandbox accounts
So this example only works with verified accounts, ie, not in sandbox anymore. If you're account is still in sandbox you can't use this library because the base url for sandbox accounts is different, you may have to use the bare rest API for that. It's a bit tricky, I might write a separate tutorial on that, or simply make a PR to the package to add sandbox support
Useful links
- Pesepay developer documentation
- Pesepay official Node.js library
- Pesepay unofficial library in this post
In the meantime, if you have any questions, reach out to me on twitter at https://twitter.com/ncubegiven_
As always, Happy coding!