Integrate iPay Africa APIs into your Strapi Application

Strapi - Feb 9 '23 - - Dev Community

iPay is an African payment processing company located in Kenya, Uganda, Tanzania, Rwanda, and the Democratic Republic of the Congo. They offer billing solutions like point-of-sale systems, e-commerce platforms for online transactions, and APIs for customer-to-business, business-to-business, and business-to-customer payments.

Their STK push API allows the creation of a USSD-based payment modal that can be sent to a customer's mobile phone. This modal includes payment details like the account number and total amount to be paid and prompts the customer to enter their PIN to authorize the transaction through their mobile money account. The STK modal is compatible with major African networks providers like Safaricom, Airtel, and Equitel.

This tutorial will use a Strapi server instance to trigger and send the modal to our user’s mobile devices.

Prerequisites

  • NodeJS installation
  • Basic Vue 3 Knowledge
  • Integrated Development Environment, I use VS code, but you are free to use others.
  • Prior Strapi knowledge is helpful but not required - Learn the basics of Strapi v4.
  • Basic JSON Web Token Authentication Knowledge.

Why use iPay with Strapi

iPay enables us to offer payment options commonly used in Africa, including card transactions and mobile money transfers through major African telecom networks. As a billing provider, iPay also provides merchants with PDQ machines (also known as "Process Data Quickly" machines) to process card transactions. Using Strapi, we can set up hooks triggered when a transaction is processed through the iPay PDQ machine.

We can automate some iPay payments by setting up Strapi Cron Jobs, allowing us to make bulk payments regularly. This ensures that our suppliers or employees receive their payments on time. iPay uses PCI DSS security standards to ensure the safety of all transactions. They are recognized by central banks in the countries they operate in. iPay is also listed as a trusted company and payment facilitator by Visa, providing reassurance that our money will not be lost in the event of an unexpected shutdown in a specific country.

Initializing a Strapi Project

To get started with our ebook store, we will create our project folder consisting of the Strapi instance (back-end) and the Vue application (front-end). To run both Vue and Strapi commands, have Node.js installed.

Run the following command to create a scaffold of the Strapi server.

    npx create-strapi-app@latest backend --quickstart
Enter fullscreen mode Exit fullscreen mode

The argument @latest fetches the latest version of Strapi from the npm package library.

The command creates a bare-bone Strapi system, fetches and installs necessary dependencies from the package.json file then initializes an SQLite Database. Other database management systems can be used using the following guide. Once every dependency has been installed, your default browser will open and render the admin registration page for Strapi. Create your administrator account then you’ll be welcomed with the page below

Strapi Admin Welcome Screen

Creating Collections

Using Strapi’s content builder plugin, we will define a schema to guide the database on how we want our tables and columns configured on SQLite.

Book Collection

This collection will save book details such as name, price, description, thumbnail, and file path. Follow the steps below to create the collection.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Book as the Display name and click Continue.
    Create Collection modal

  4. Click the Text field button, then type name within the name text input field. This field will be used to store the name of the book.

  5. Click the add another field button, then repeat the above step to set the description field. The description will be a detailed summary of the specified book.

  6. Using the same steps, select the Number field button, then type price and select integer (ex: 10). The price field is used to store the selling price of a book.

  7. Create two media fields, one to store the book’s image and the other one for the actual book(.pdf, .epub).
    Book collection configuration

Strapi automatically handles how files will be stored and represented in the database. We will be able to populate the field and retrieve the paths of the files

Sale Collection

This collection will contain details needed to verify a transaction.

  1. Click on Content-type Builder under plugins in the side navigation bar.
  2. Click on Create new collection type.
  3. Type Sale for the Display name and click Continue.
    Create Collection modal.

  4. Click on the relation field button, then select “book” on the dropdown. Make sure the Sale has one book option selected.
    Relations in the Admin Panel

  5. Create text fields for the following: phone, receipt, ref, and status. The phone field will store the mobile number used to make the payment. The receipt field saves a telecom provider-generated value after a transaction is successful. Finally, the status field will be populated with a value from iPay servers.

  6. Add another relation by clicking the relation field button and selecting user(from: users-permissions) from the drop-down. Within the sale dialog box, type buyer within the field name input field. Make sure the User has many Sales option is selected.
    One-to-many relationship between a user and a sale.

  7. Add a number field named total and set the number format to be integer (ex: 10). Its value will be the transaction amount sent to iPay servers for processing.

Creating a Merchant Account with iPay Africa

To begin accepting payments through iPay, it is necessary to open an account with them. To do this, navigate to the merchant registration page on your browser. The page includes a form requesting information such as your country, organization type, industry, website, and name. Additionally, you will need to provide several documents for verification purposes, including licenses and registration certificates specific to your organization's location.

iPay Merchant Registration Form

Financial solutions providers, such as banks, and payment processors, such as iPay, collect client information for several reasons. One of the main reasons is to comply with regulations, such as the Know Your Customer (KYC) and Anti-Money Laundering (AML) laws. These regulations require financial institutions to collect and verify the identity of their clients to prevent money laundering, financing of terrorism, and other illegal activities.

Another reason is to validate transactions and prevent fraud. Financial solutions providers use client information to validate transactions and confirm that the person making the transaction is who they claim to be. This helps to prevent fraud and protect the security of the financial system. You must upload valid licenses and operation permits using the form below.

Upload fields for necessary business certificates

iPay also needs to know some of the top-level managers of your organization. They provide fields that capture a director’s identification document and tax identification certificates.

You could add as many directors as you would like. These managers are typically responsible for the overall operation and management of the company. They are the ones who are legally responsible for the company and are held accountable for any violation of laws and regulations.

So, it is important that they are involved in the Merchant registration process to ensure that the company complies with all relevant laws and regulations.

Upload fields for top level management details

You will also be required to select the main purpose of creating a Merchant account. Since iPay is a payment solution provider, we could use it to accept payments from cards and mobile money networks such as Mpesa.

Furthermore, it could invoice and bill your customers and process bulk payments. Provide the most appropriate use cases for your organization, which will hasten the verification process.

Form fields for account settlements

The remittance form gives you options such as the primary currency of your merchant account, daily remittance amount, and primary withdrawal method. Since iPay is available in most countries, you could select the primary currency of your organization’s jurisdiction. However, the US dollar could be the best option since it is accepted in all banks.

Lastly, iPay requires you to provide your organization’s bank details. This information will set up a bank payout option for your merchant account. You can withdraw to any bank worldwide since the form contains a field that captures global remittance bank codes such as IBAN and Swift.

Bank Account Information iPay

Once you have filled in all the required fields in the registration form, the information you have provided will be reviewed. They might call to seek clarification. Your account will then be activated and they’ll email you a vendor identifier which we will refer to as the iPayVid in this tutorial. The email will also contain a system-generated password, which will be used to login to the iPay Dashboard.

iPay Dashboard

Setting Up iPay Configs and Functions

To use iPay APIs, we need to set up some environment variables. To do this, open the .env file in the root folder of the server and add the fields iPayKey, iPayVid, and iPaySecret. You can find these fields on your iPay dashboard. These fields will create hashes attached to all requests sent to the iPay server, allowing them to identify which merchant is making the transaction through the APIs.

    //./backend/.env
    HOST=0.0.0.0
    PORT=1337
    APP_KEYS="toBeModified1,toBeModified2"
    API_TOKEN_SALT=tobemodified
    ADMIN_JWT_SECRET=tobemodified
    JWT_SECRET=tobemodified
    iPayKey=YOUR_KEY
    iPayVid=YOUR_VENDOR_ID
    iPaySecret=YOUR_SECRET
Enter fullscreen mode Exit fullscreen mode

To use the keys you added to the .env file, you need to load them into the application. You can add the keys iPaykey, iPayvid, and iPaysecret to the module in the server.js file located in the config directory. Then, use the env function to populate these keys with their respective values as defined in the .env file. This will allow the application to access the keys and use them in the appropriate way

    //server.js
    module.exports = ({ env }) => ({
      host: env('HOST', '0.0.0.0'),
      port: env.int('PORT', 1337),
      app: {
        keys: env.array('APP_KEYS'),
      },
      iPayKey: env('iPayKey'),
      iPayKey: env('iPayVid'),
      iPayKey: env('iPaySecret'),
    });
Enter fullscreen mode Exit fullscreen mode

To add custom logic to the Strapi server, you need to include some functions in the index.js file located in the src directory. These functions will be exported into a Strapi object. There are two functions that can be used to extend the default Strapi functionality: register and bootstrap. We will use the bootstrap function to return a Strapi object with embedded iPay functions. This function allows us to customize the behavior of the Strapi server by adding additional functionality.

In the bootstrap function block, initialize the following variables to contain various iPay endpoints and the secrets loaded from the env file. Since part of the payload will be hashed, define a variable to specify the algorithm the hashing function should use. These variables will be used to interact with the iPay endpoints and secure the payload as it is transmitted.

    //./backend/src/index.js
    async bootstrap({ strapi }) {

    let iPayTransact = "https://apis.ipayafrica.com/payments/v2/transact";
    let iPayMpesa = "https://apis.ipayafrica.com/payments/v2/transact/push/mpesa";
    let iPayTransactMobile = "https://apis.ipayafrica.com/payments/v2/transact";
    let iPayQueryTransaction = "https://apis.ipayafrica.com/payments/v2/transaction/search";
    let iPayRefund = "https://apis.ipayafrica.com/payments/v2/transaction/refund";
    let iPaySecret = strapi.config.get('server.ipaysecret', 'demoCHANGED');
    let iPayVid = strapi.config.get('server.ipayvid', 'demo');let iPayAlgorithm = "sha256";

    }
Enter fullscreen mode Exit fullscreen mode

Payload Initialization

To request that an STK push modal be sent to a specific phone number, we must first prepare the payload and hash it. The function below extracts the necessary information from its parameters and then creates an object with that information. The p1, p2, and p3 keys can be used to store additional information that can be used by the controller handling the callback URL request. The cst key instructs the iPay server to send transaction receipts to the client. We, by default, disabled this option by assigning a value of 0 to the cst key.

    //./backend/src/index.js
    function prepare_stk_data(order_id, amount, customer_phone, customer_email, customer_notifications = 0) {

          let iPayData = {
            "live": 0,
            "oid": order_id,
            "inv": order_id,
            "amount": amount,
            "tel": customer_phone,
            "eml": customer_email,
            "vid": iPayVid,
            "curr": "KES",
            "p1": "YOUR-CUSTOM-PARAMETER",
            "p2": "YOUR-CUSTOM-PARAMETER",
            "p3": "YOUR-CUSTOM-PARAMETER",
            "p4": "YOUR-CUSTOM-PARAMETER",
            "cbk": "YOUR_CALLBACK_URL",
            "cst": customer_notifications,
            "crl": 0,
            "autopay": 1
          }
          // The hash digital signature hash of the data for verification.
          let hashCode = `${iPayData['live']}${iPayData['oid']}${iPayData['inv']}${iPayData['amount']}${iPayData['tel']}${iPayData['eml']}${iPayData['vid']}${iPayData['curr']}${iPayData['p1']}${iPayData['p2']}${iPayData['p3']}${iPayData['p4']}${iPayData['cst']}${iPayData['cbk']}`

          //creating hmac object 
          let hash = crypto.createHmac(iPayAlgorithm, iPaySecret).update(hashCode).digest("hex");
          iPayData['hash'] = hash;
          return iPayData;
        }
Enter fullscreen mode Exit fullscreen mode

Request STK Push on iPay Servers

The function below consumes the payload from the [prepare_stk_data](https://github.com/i1d9/strapi-ipay/blob/c2bcaf8c92fa977b51078686bfc3b4f81ed7ee48/backend/src/index.js#L33) function we created earlier. It prepares the iPay server to receive our STK push request. After sending the payload using a POST request, the response contains a session identifier that we will use to send another request to finally send the STK modal. This process allows us to initiate the STK push process and ensure that the correct information is transmitted to the iPay server.

    //./backend/src/index.js
    async function initateSTKTransaction(order_id, customer_tel, customer_email, amount, send_receipt = 0) {
          let data = prepare_stk_data(order_id, amount, customer_tel,
            customer_email, send_receipt);
          try {
            let response = await axios({
              method: 'post',
              url: iPayTransact,
              data: JSON.stringify(data),
              headers: {
                "Content-type": "application/json"
              }
            });
            response.data.tel = data.tel;
            response.data.vid = data.vid;
            return response.data;
          } catch (e) {
            return null;
          }
        }
Enter fullscreen mode Exit fullscreen mode

Direct STK Push to Client

The function below makes a POST request to the iPay servers using a payload returned by the initiateSTKTransaction function. Both of these functions have the same parameters. Depending on the status code of the initiate STK Transaction function, we create a hash using the createHmac function.

We then convert the payload to a JSON string after appending the phone number, vendor identifier (vid), and session identifier (sid). If the request fails, we return null. Otherwise, we return the response body to the calling function. This function allows us to finalize the STK push process and receive a response from the iPay server indicating the request status.

    //./backend/src/index.js
    async function sendSTK(order_id, customer_telephone, customer_email, amount) {
    try {
    let init_response = await initateSTKTransaction(order_id, customer_telephone, customer_email, amount);

    if (init_response.status == 1) {
    let hashCode = `${customer_telephone}${init_response.vid}${init_response.data.sid}`;
    let hash = crypto.createHmac(iPayAlgorithm, iPaySecret).update(hashCode).digest("hex");
    let stkData = {
    phone: customer_telephone,
    sid: init_response.data.sid,
    vid: init_response.vid,
    hash: hash
    }
    let response = await axios({
    method: 'post',
    url: iPayMpesa,
    data: JSON.stringify(stkData),
    headers: {"Content-type": "application/json"}
    });

    return response.data;
    }
      return null;
    } catch (error) {return null;}
    }
Enter fullscreen mode Exit fullscreen mode

Transaction Verification

After initiating a transaction, we need to check whether it was successful. If the transaction is successful, the user enters the correct PIN in the STK modal. If anything goes wrong, a transaction record is not saved. The function below queries a transaction using its order identifier (order_id) we will define it within the index.js file in the src directory.

We interpolate the order identifier obtained from the function parameters with our vendor identifier and then hash it. We append the hash to an object containing both the order identifier and vendor id (iPayVid), then convert it to a JSON string. If the request is successful, we return the full response body. This function allows us to check the status of a transaction and ensure that it was completed successfully.

    //./backend/src/index.js
    async function checkTransactionStatus(order_id) {
    let hashData = `${order_id}${iPayVid}`;
    let hash = crypto.createHmac(iPayAlgorithm, iPaySecret).update(hashData).digest('hex');
          let data = {
            "oid": order_id,
            "vid": iPayVid,
            "hash": hash
          }
          try {
            let response = await axios({
              method: 'post',
              url: iPayQueryTransaction,
              data: JSON.stringify(data),
              headers: {
                "Content-type": "application/json"
              }
            });
            return response.data;
          } catch (error) {
            console.log("An error occurred while looking for the transaction.");
            console.log(error);
            return null
          }
    }
Enter fullscreen mode Exit fullscreen mode

Lastly, we must add all the functions we have created to the Strapi object defined within the parameters of the bootstrap function. The bootstrap function returns the Strapi object enabling us to access the iPay functions on collection controllers and cron job functions if we want to schedule payments.

    //./backend/src/index.js
    async bootstrap({ strapi }) {

    /**
      Definitions of the iPay functions

      function prepare_stk_data(order_id, amount, phone, email, notifications = 0) {...}
      async function initateSTKTransaction(order_id, phone, email, amount, receipt) {...}
      async function sendSTK(order_id, phone, email, amount) {...}
      async function checkTransactionStatus(order_id) {...}
    **/

    /// Export iPay functions
     strapi.ipay = {
          checkTransactionStatus,
          sendSTK, 
        }
      },
    };
Enter fullscreen mode Exit fullscreen mode

Extending Collection APIs: Controllers and Routes

A controller handles HTTP requests and is usually associated with a specific collection. It includes basic CRUD functions based on the type of request method used. For example, a PUT request will call the update function. These functions are defined within the Strapi dependency in the package.json file. The source code of those functions can be found on GitHub. To override the functions, we will redefine them within the sale controllers directory in the sale.js file. Specifically, we will override the create and findOne functions.

Within it, we will associate a sale with a book and create a reference number that will be used by the sendStk function to identify transactions uniquely. After requesting payment, we will create a function that will run after a certain period of time to check whether the transaction was successful. This process allows us to handle the various steps involved in initiating and completing a transaction through the iPay APIs.

    //../src/api/sale/controllers/sale.js
    async create(ctx) {
            let ref = (Math.random() + 1).toString(36).substring(2);
            let book = await strapi.entityService.findOne('api::book.book', ctx.request.body.book, {
                populate: ['file']
            });
            strapi.ipay.sendSTK(ref, ctx.request.body.phone, ctx.state.user.email, book.price);
            let sale = await strapi.entityService.create('api::sale.sale', {
                data: {
                    ...ctx.request.body,
                    buyer: ctx.state.user.id,
                    ref,
                    total: book.price
                }
            });
            ctx.body = sale;
            setTimeout(async () => {
            let transact_result = await strapi.ipay.checkTransactionStatus(ref);
            if (transact_result) {
                console.log(transact_result);
                await strapi.entityService.update('api::sale.sale', sale.id, {
                    data: {
                        receipt: transact_result.data.transaction_code,
                        status: "confirmed"
                    },
                });
            }
            }, 420 * 1000);
        }
Enter fullscreen mode Exit fullscreen mode

The findOne function fetches a single sale item using a GET request on the route /api/sale/:id. We will override this function by redefining it within the sale.js file (/src/api/sale/controllers/sale.js).

We will then add some logic to check whether the current user has purchased the book. If they have, we will append the file path of the file to the HTTP response so that they can download it. If the current user (ctx.state.user) has not purchased the book, we will preload the image. This function allows us to manage access to the book and ensure that only authorized users can download it.

    //../src/api/sale/controllers/sale.js
    async findOne(ctx) {
    const { id } = ctx.params;
    let sale = await strapi.entityService.findOne('api::sale.sale', id, {
                populate: {
                    buyer: {
                        fields: ["username"],
                        filters: {
                            id: {
                                $eqi: ctx.state.user.id,
                            },
                        },
                    },
                    book: true
                },
            });

            if (sale.buyer) {
                let book = await strapi.entityService.findOne('api::book.book', sale.book.id, {
                    populate: ['file', 'image'],
                });
                if (sale.status != "confirmed") delete book.file
                ctx.body = {
                    book,
                    sale
                }
            } else {
                ctx.status = 401;
                ctx.body = {
                    message: "Invalid Request"
                };
            }
        },
Enter fullscreen mode Exit fullscreen mode

Still, within the sale.js file, we will create a new controller function. This function will be invoked by a custom route. The function uses Strapi's entity service API to fetch a single sale record using the id and preload the buyer relationship.

It then filters the result by the id of the currently logged-in user to ensure that a user can only check their sale status. If the sale status is not pending or is not found (null), we will change the response status code to 401 and return an error message as a response. This function allows us to manage access to the sale status and ensure that only authorized users can view it.

    //../src/api/sale/controllers/sale.js
    async checkStatus(ctx) {
    const { id } = ctx.params;
    let sale = await strapi.entityService.findOne('api::sale.sale', id, {
     populate: { buyer: { fields: ["username"], filters: { id: {$eqi: ctx.state.user.id,},},},
    book: true
    },});
    if (sale.buyer && sale.status == "pending") {
            let transact_result = await strapi.ipay.checkTransactionStatus(sale.ref);
            if (transact_result && transact_result.hasOwnProperty("data") && transact_result.data.hasOwnProperty("transaction_code")) {
                    await strapi.entityService.update('api::sale.sale', sale.ref, {
                        data: {
                            receipt: transact_result.data.transaction_code,
                            status: "confirmed"
                        },
                    });
                    ctx.body = true;
                } else {
                    ctx.body = false;
                }
            } else {
                ctx.status = 401;
                ctx.body = {
                    message: "Invalid Request"
                };
            }

        }
Enter fullscreen mode Exit fullscreen mode

We will create another route that will be used to check the status of a sale transaction. If the status of the queried sale is pending, we will invoke the checkTransactionStatus function using the retrieved sale reference. We must create two new files within the routes folder in the sale API folder: 01-sale.js and 02-sale.js. The second file will contain the default route setup, so we will copy the content from sale.js and paste it into 02-sale.js.

This process allows us to set up the necessary routes and functions to check a sale's transaction status. Routes are loaded in a specific order, with our new routes being loaded first, followed by the routes for other Strapi collections. This ensures that our custom routes are available to handle requests before the default routes are checked.

Custom Sale Route files

The 01-sale.js file includes a module that exports the configuration for our new route. We are adding a new GET request route whose endpoint is at localhost:1337/api/sales/:id/status. This route is handled by the checkStatus function located within the sale controller. To verify that our routes have been added, we can run the following Strapi CLI command.

    $ yarn strapi routes:list
Enter fullscreen mode Exit fullscreen mode

Command output

Based on the output, we can see that our custom route was loaded first, followed by the other default routes.

Initializing the Vue Application

We will set up our front-end Vue application generator using the following command.

    npm install -g @vue/cli
    # OR
    yarn global add @vue/cli
Enter fullscreen mode Exit fullscreen mode

Once the CLI has been installed, run the following command to create the project.

    vue create frontend
Enter fullscreen mode Exit fullscreen mode

Vue Terminal

Select Vue 3 when prompted, then wait for it to install the necessary dependencies using your default node package manager (npm, yarn, or ppm). The command creates a Vue3 application with basic components.

Installing Auxiliary Dependencies

We need to add additional libraries to help our front-end application be more purposeful. Below are the required dependencies:

Vue-router

This dependency will allow us to render components and supports dynamic route matching, which we will use to display the details of a specific book. We will define an id placeholder on the path and then use it in the detail component to retrieve the details of a specific book. The Vue Router will also handle authenticated views, ensuring that certain components are only rendered when a valid JWT token is available. This allows us to manage access to specific components and ensure that only authorized users can view them.

Vuex

This state management library will handle the state of our application. It will be responsible for saving authentication tokens and some of the user's details and follows some of Redux's state management patterns by enclosing the state, mutations, getters, and actions in a single object. Most functions that handle HTTP requests will be located within the actions block. Getters will retrieve specific state values, and mutations will be used to modify state values. This allows us to manage and manipulate the application's state in a structured and organized manner.

Vuex-persistedstate

This library allows us to save state values in the browser, preventing the user from logging in every time they want to purchase a book on our site. We can also use this library to save user preferences, such as the default theme and language. The library directly integrates with Vuex, allowing us to easily manage the saved state values and use them within our application.

Axios

We will use this promise-based library to send HTTP requests to the Strapi server. It supports JSON data transformation and has an in-built cross-site forgery protector. We can also write interceptors, which are logic that is run before a request is sent or after we have started receiving a response from the server. This library allows us to send HTTP requests and handle responses within our application easily.

Bootstrap

We will use this CSS framework to build mobile-responsive user interfaces for our components. Many different frameworks are available for use, and you can integrate them into the application. However, Bootstrap is a good choice for this simple project because it is beginner-friendly and has well-written documentation. This allows us to easily create professional-looking interfaces and ensure that our application is responsive across different devices.

To install the libraries, run the following command.

    # Using NPM
    $ npm i bootstrap bootstrap-vue-3 axios vuex-persistedstate vuex

    # Using Yarn
    $ yarn add bootstrap bootstrap-vue-3 axios vuex-persistedstate vuex
Enter fullscreen mode Exit fullscreen mode

After installing the necessary dependencies, we can start setting up Vuex and vue-persistedstate. We will create a file named [store.js](https://github.com/i1d9/strapi-ipay/blob/9893af6850a3130b74e09c0f2a30e4782a211d50/frontend/src/store.js) within the src folder and include the following scaffold: we will initialize the state with four items: book, books, auth, and sale.

These will be the initial values of the state, with auth being set to null and modified after a successful login or creation of user accounts. Within the export block, we will plug in the state persistor, which stores the state in local storage by default.

The plugin can be configured to be cookie-based, and more information can be found here. This process allows us to set up Vuex and vue-persistedstate and initialize the application's state.

    import Vuex from 'vuex';
    import axios from 'axios';
    import createPersistedState from "vuex-persistedstate";
    const dataStore = {
        state() {
            return {
                book: {},
                books: [],
                auth: null,
                sale: {}
            }
        },
        mutations: {},
        getters: {},
        actions: {}
    }
    export default new Vuex.Store({
        modules: {
            dataStore
        },
        plugins: [createPersistedState()]
    })
Enter fullscreen mode Exit fullscreen mode

UI Components and Routes

We will map various components to specific routes so that a particular component is rendered when a specific route is called on the user's browser. To begin this process, we will open main.js within the src folder and add the following code. This allows us to associate specific components with routes and control which components are displayed to the user.

    //./src/main.js
    import { createApp, h } from 'vue'
    //Bootstrap CSS Framework
    import "bootstrap/dist/css/bootstrap.min.css"
    import App from './App.vue'
    import { createRouter, createWebHashHistory } from "vue-router";
    import store from './store';
    import Home from "./components/Home.vue";
    import Checkout from "./components/Checkout.vue";
    import Detail from "./components/Detail.vue";
    import Login from "./components/Login.vue";
    import Register from "./components/Register.vue";
    import Sale from "./components/Sale.vue";
    //Mapping Routes to Components
    const routes = [
        { path: "/", component: Home, meta: { requiresAuth: false }, },
        { path: "/:id", component: Detail, meta: { requiresAuth: true },},
        { path: "/checkout", component: Checkout, meta: { requiresAuth: true },},  
        { path: "/login", component: Login , meta: { requiresAuth: false },},  
        { path: "/register", component: Register,meta: { requiresAuth: false },},  
        { path: "/sale/:id", component: Sale,meta: { requiresAuth: true },},  
    ];
    const router = createRouter({
        history: createWebHashHistory(),
        routes,
    });
    router.beforeEach((to, from) => {
        if (to.meta.requiresAuth && !store.getters.getAuth) {
            from
            return {
                path: '/login',
                // save the location we were at to come back later
                query: { redirect: to.fullPath },
            }
        }
    });
    const app = createApp({
        render: () => h(App),
    });
    app.use(router);
    app.use(store);
    app.mount('#app');
    //Bootstrap JS Helpers
    import "bootstrap/dist/js/bootstrap.js"
Enter fullscreen mode Exit fullscreen mode

We started by importing the CSS files from the Bootstrap library we installed, followed by the state manager (store.js). We also imported all the necessary UI components that will be used by routes.

The routes variable contains a list of objects with the path name, component, and a boolean value that defines whether the path requires authentication. This allows us to set up the necessary dependencies and define the routes that will be available within the application.

Home Component

This component is rendered when the index route is accessed. It has a child component which is a basic bootstrap card. The card displays the book’s price, name, and thumbnail. The home component uses one of the functions defined in the state manager’s getter block to list all books.

Once the component is created, we use the listBooks function defined in the state manager’s file to retrieve available books from the server. Available books are rendered within a Vue for loop and various book attributes are passed to the card child component.

    <template>
    <div>
     <ul v-for="book in books" :key="book.id">
     <Card :price="book.attributes.price" :name="book.attributes.name" :id="book.id" :image="book.attributes.image" />
     </ul>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    import Card from "./Card.vue";
    export default {
        name: "HomeComponent",
        methods: {
            ...mapActions(["listBooks"]),
        },
        computed: {
            books() {
                return this.$store.getters.getBooks || [];
            }
        },
        created() {
            this.listBooks();
        },
        components: { Card }
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

Detail Component

This component uses the Vue router’s dynamic route matching to extract a book’s id from the URL. The id is then used to make a GET request to the server, which then responds with a book's details. The component contains a child component named modal which uses Vue slots allowing us to dynamically change a bootstrap modal’s body.

The modal is opened when the buy now button is pressed. Within the modal is a checkout component containing a form tag with the necessary inputs to populate the Sale schema on the back-end.

    //Detail.vue
    <template>
    <div>
    <h2 class="display-2"></h2>
    <img :src="'http://localhost:1337' +  book.attributes.image.data.attributes.formats.large.url" :alt="book.attributes.image.data.attributes.alternativeText" />
    <p></p>
    <p>KES </p>
     <Modal>
     <template #title>Billing Details</template>
     <template #body>
      <Checkout :book_id="book.id" :book="book.attributes" />
     </template>
     </Modal>
     <!-- Button trigger modal -->
     <button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#checkoutModal">Buy Now
     </button>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    import Modal from "./Modal.vue";
    import Checkout from "./Checkout.vue";
    export default {
        name: "DetailComponent",
        components: { Modal, Checkout },
        computed: {
            book() {
                return this.$store.getters.getBook || {};
            }
        },
        created() {
            this.listBook(this.$route.params.id);
        },
        methods: {
            ...mapActions(["listBook"]),
        }
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

Login and Register Component

These components are responsible for modifying the auth object of our application’s state. They are rendered when the user accesses the paths /login and /register. The components use the signIn and signUp functions when the submit button is pressed. An error message is conditionally rendered when the requests don't reply with the status 200 HTTP code.

    //Login.vue
    <template>
    <div class="container my-5">
    <form @submit.prevent="submit" class="d-flex flex-column">
     <div class="mb-3">
     <input type="email" class="form-control" placeholder="Email Address" name="email" v-model="form.email" />
     </div>
     <div class="mb-3">
     <input class="form-control" type="password" name="password" v-model="form.password" placeholder="Password" />
     </div>
     <button type="submit" class="btn btn-success m-1">Login</button>
     <router-link to="/register" class="btn btn-outline-primary m-1">
      Register
     </router-link>
    </form>
    <p v-if="showError" id="error">Invalid Email/Password</p>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " LoginComponent",
        data() {
            return {
                form: {
                    email: "",
                    password: "",
                },
                showError: false
            }
        },
        methods: {
            ...mapActions(["signIn"]),
            async submit() {
                try {
                    await this.signIn({
                        identifier: this.form.email,
                        password: this.form.password
                    });
                    this.$router.push(this.$route.query.redirect);
                    this.showError = false;
                } catch (error) {
                    this.showError = true;
                }
            }
        }
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

The register component contains an additional input field to capture a user’s username. Both the username and email can be used to authenticate our application.

    //Register.vue
    <template>
    <div  class="container my-5">
    <form @submit.prevent="submit" class="d-flex flex-column">
    <div class="mb-3">
    <input type="text" class="form-control" placeholder="Username" name="username"  v-model="form.username" />
    </div>
    <div class="mb-3">
    <input type="email" class="form-control" placeholder="Email Address" name="email"
    v-model="form.email" />
    </div>
    <div class="mb-3">
    <input type="password" class="form-control" name="password" v-model="form.password" placeholder="Password" />
    </div>
    <button type="submit" class="btn btn-success m-1">Create Account</button>
    <router-link to="/login" class="btn btn-outline-primary m-1">Login</router-link>
    </form>
    <p v-if="showError" id="error">Could not create an account with the details provided</p>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: " RegisterComponent",
        data() {
            return {
                form: {username: "",email: "",password: "",},
                showError: false
            }
        },
        methods: {
            ...mapActions(["signUp"]),
            async submit() {
                try {
                    await this.signUp({
                        username: this.form.username,
                        password: this.form.password,
                        email: this.form.email,
                    });
                    this.$router.push("/");
                    this.showError = false
                } catch (error) {
                    this.showError = true
                }
            }
        }
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

Checkout Component

This component is rendered inside a modal within the detail component. It captures the phone number which will receive the STK modal. When the form is submitted, the server sends the modal using the functions we had created. The component redirects the user to the sale route(/sale/:id) using a sale id received from the server.

    //Checkout.vue
    <template>
    <div>
    <div><h3></h3></div>
    <div><h3>Price: KES </h3></div>
    <div class="col-md-8 order-md-1">
    <form @submit.prevent="submit">
    <div class="mb-3">
    <label for="address">Phone Number</label>
    <input type="text" class="form-control" id="address" placeholder="2547XXXXXXXX" v-model="form.phone" required>                    
    </div>
    <button class="btn btn-primary btn-lg btn-block" type="submit">Complete Payment</button>
    </form>
    </div>
    </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: "CheckoutComponent",
        props: ['book', 'book_id'],
        data() {return {form: {phone: "", book: this.book_id,}}},
        methods: {
            ...mapActions(["makePurchase"]),
            async submit() {
                try {
                    let result = await this.makePurchase(this.form);
                    this.$router.push(`/sale/${result.id}`) 
                } catch (error) {
                    console.log(error);
                }
            }
        },
        computed: {auth() {return this.$store.getters.getAuth;}}
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

Sale Component

This component is rendered after a sale has been initiated from the checkout component. Using dynamic route matching, the sale id is extracted from the URL path and then used to check the sale’s status on the server. We had defined a setTimeout function on the controller function named create, which is supposed to check a sale’s status using the reference field and then update the status of the specified sale.

If the status value is set to confirmed, we send a request to the same route and then retrieve the book’s absolute path. The user can then download the book through that path. The sale component contains the download button for the bought book. The button is conditionally rendered and is only visible when the status is set to confirmed; else, the check status button is displayed.

    //Sale.vue
    <template>
        <div>
            <div>
                <h1></h1>
                <h2></h2>
                <img :src="'http://localhost:1337' +  book.image.formats.large.url" :alt="book.image.alternativeText" />
                <p></p>       
                <span v-if="sale.status == 'confirmed'">
                    <a :href="'http://localhost:1337' + book.file.url">Download</a>
                </span>
                <button @click="refreshStatus" class="btn btn-primary" type="button" v-else>
                    Check Status
                </button>

            </div>
        </div>
    </template>
    <script>
    import { mapActions } from "vuex";
    export default {
        name: "SaleComponent",
        computed: {
            book() {
                return this.$store.getters.getBook || {};
            },
            sale() {
                return this.$store.getters.getSale || {};
            },
        },
        created() {
            this.myPurchase(this.$route.params.id);
        },
        methods: {
            ...mapActions(["myPurchase", "myPurchaseStatus"]),
            refreshStatus(){
                this.myPurchaseStatus(this.$route.params.id);
            }   
        }
    }
    </script>
Enter fullscreen mode Exit fullscreen mode

iPay STK Push Modal on Safaricom Network

Conclusion

In this tutorial, we have built an online bookstore powered by Strapi and uses iPay Africa APIs to handle purchases. After a successful transaction, users can download a book to their devices. The tutorial explains how to extend controller functions, add custom routes and use JWT tokens generated by Strapi to authenticate our Vue front-end application.

You can find and download the source code for the entire project in this repository.

If you're interested in discussing this topic further or connecting with more people using Strapi, join our Discord community. It is a great place to share your thoughts, ask questions, and participate in live discussions.

Strapi Enterprise Edition

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .