Implementing a Custom Payment Gateway Integration in MedusaJS

TonieTonie
13 min read

When it comes to processing online payments, offering a diverse range of payment options is crucial for e-commerce applications. While MedusaJS provides native support for popular payment gateways, you may have specific requirements that necessitate integrating a custom payment gateway like Paystack. In this article, we will guide you through the process of implementing Paystack as a custom payment gateway in MedusaJS, empowering you to offer seamless payment experiences to your customers.

What is MedusaJS?

Medusa is an open-source, composable commerce platform specifically designed for developers. With its flexible architecture, MedusaJS empowers users to build highly customized commerce solutions catering to their needs. By providing modular commerce infrastructure, Medusa simplifies the custom development process, allowing developers to focus on creating unique and tailored e-commerce experiences.

medusa e-commerce architecture

You can learn more about the Medusa architecture in their official documentation.

One of the key strengths of MedusaJS is its ability to leverage cutting-edge infrastructure technologies. It embraces serverless architecture and employs edge workers to ensure exceptional scalability, reliability, and performance. This enables online stores built on Medusa to handle increasing traffic and deliver seamless user experiences, even during peak periods.

Why Paystack?

Paystack is a popular payment gateway used across several countries in Africa. It offers a developer-friendly API, and robust security features, and supports various payment methods.

Integrating Paystack into a Medusa store offers several benefits, including a developer-friendly API, robust security measures, and support for multiple payment methods.

Prerequisite

To fully understand the content of this article, you will need the following:

  1. A recent version of node v16+

  2. Yarn (or npm) installed on your computer

  3. A code editor

  4. A Paystack account (If you do not have one you can sign up using this link)

  5. Git

  6. PostgreSQL

Setting up Medusa Backend and Admin Dashboard

In this section, we'll go through how to set and configure the Medusa backend and admin dashboard.

The Medusa Backend acts as the core element accountable for managing the logic and data of the store. It provides REST APIs that are utilized by the Medusa Admin and Storefront for tasks such as data retrieval, creation, and modification.

Installing the Medusa backend and Admin Dashboard

Follow these steps to install the Medusa backend and admin dashboard.

Open your terminal and run the following command:

yarn create medusa-app
  • You'll be prompted to enter the name of your project which will be used to create your project directory. You can use the default name my-medusa-store or any name of your choice.

  • Next, you will be asked to provide the necessary PostgreSQL database credentials, including the username and password. If the credentials are valid, you can proceed to the next step.

    These credentials will be utilized to establish a database during the setup process and configure your Medusa backend to establish a connection with that database.

  • You'll then be asked to enter an email for your admin user. This email will be used to signup on your admin dashboard.

  • After these, the setup process will begin and on completion, the Medusa backend will start and the admin dashboard will be opened in your default browser. This is where you sign up using the email you provided during the setup process.

  • Follow the tutorial steps on the dashboard to create your first product.

Installing packages and configuring the backend.

Open your terminal and type in the following command to install the medusa-payment-paystack plugin.

yarn add medusa-payment-paystack

This adds Paystack as a payment provider to Medusa e-commerce stores.

Open your project with your code editor and go to the .env file. Here you'll have to add your paystack secret key using the following format You can get your secret key from the setting page on your paystack admin dashboard.

PAYSTACK_SECRET_KEY=yourpaystacksecretkey

Navigate to the `medusa-config.js` file and add the following lines of code within the plugins array

  {
    resolve: `medusa-payment-paystack`,
    options: {
      secret_key: PAYSTACK_SECRET_KEY, //declare this as a variable just above your plugin
    },
  },

Your medusa-config.js file should look like this

const dotenv = require("dotenv");

let ENV_FILE_NAME = "";
switch (process.env.NODE_ENV) {
  case "production":
    ENV_FILE_NAME = ".env.production";
    break;
  case "staging":
    ENV_FILE_NAME = ".env.staging";
    break;
  case "test":
    ENV_FILE_NAME = ".env.test";
    break;
  case "development":
  default:
    ENV_FILE_NAME = ".env";
    break;
}

try {
  dotenv.config({ path: process.cwd() + "/" + ENV_FILE_NAME });
} catch (e) {}

// CORS when consuming Medusa from admin
const ADMIN_CORS =
  process.env.ADMIN_CORS || "http://localhost:7000,http://localhost:7001";

// CORS to avoid issues when consuming Medusa from a client
const STORE_CORS = process.env.STORE_CORS || "http://localhost:8000";

const DATABASE_URL =
  process.env.DATABASE_URL || "postgres://localhost/medusa-store";

const REDIS_URL = process.env.REDIS_URL || "redis://localhost:6379";
const PAYSTACK_SECRET_KEY = process.env.PAYSTACK_SECRET_KEY;
const STRIPE_API_KEY = process.env.STRIPE_API_KEY;

const plugins = [
  `medusa-fulfillment-manual`,
  `medusa-payment-manual`,
  {
    resolve: `@medusajs/file-local`,
    options: {
      upload_dir: "uploads",
    },
  },
  {
    resolve: `medusa-payment-stripe`,
    options: {
      api_key: STRIPE_API_KEY,
    },
  },
  {
    resolve: `medusa-payment-paystack`,
    options: {
      secret_key: PAYSTACK_SECRET_KEY,
    },
  },
  {
    resolve: "@medusajs/admin",
    /** @type {import('@medusajs/admin').PluginOptions} */
    options: {
      autoRebuild: true,
      develop: {
        open: process.env.OPEN_BROWSER !== "false",
      },
    },
  },
];

const modules = {
  /*eventBus: {
    resolve: "@medusajs/event-bus-redis",
    options: {
      redisUrl: REDIS_URL
    }
  },
  cacheService: {
    resolve: "@medusajs/cache-redis",
    options: {
      redisUrl: REDIS_URL
    }
  },*/
};

/** @type {import('@medusajs/medusa').ConfigModule["projectConfig"]} */
const projectConfig = {
  jwtSecret: process.env.JWT_SECRET,
  cookieSecret: process.env.COOKIE_SECRET,
  store_cors: STORE_CORS,
  database_url: DATABASE_URL,
  admin_cors: ADMIN_CORS,
  // Uncomment the following lines to enable REDIS
  // redis_url: REDIS_URL
};

/** @type {import('@medusajs/medusa').ConfigModule} */
module.exports = {
  projectConfig,
  plugins,
  modules,
};

And that's all for the backend. Go to your terminal and run yarn start to start up your backend server and admin dashboard.

Adding Paystack as a Payment Option on the Admin Dashboard.

With your backend now configured and running on the terminal. Open up your browser and go to http://localhost:9000/app to access your admin dashboard. Log in using your credentials and use the following steps to Add Paystack as a payment option.

  1. On the navigation panel, click on the settings button to go to the settings page.

  2. Next, click on the + icon to set up a new region or choose an existing region. click on the three dots and select Edit region to edit the details about a particular region.

  3. Enter the necessary details (Country, currency, etc). Scroll to the bottom of the page where you have Payment Providers. If you set up the Medusa backend correctly, you should see paystack as a payment option. Select it and save.

note: on the settings page you can add a new currency specific to your region by opening the currency tab.

That's all you have to do on your Admin dashboard. Next, we will setup and configure our Storefront.

Setting up and Configuring the Storefront

For the storefront, we'll be using the Medusa Nextjs starter template. Use the following steps to install.

Installing the Storefront

Open your terminal and type run the following

npx create-next-app -e https://github.com/medusajs/nextjs-starter-medusa my-medusa-storefront

After the installation process, change to the newly created directory my-medusa-storefront and rename the template environment variable file to use environment variables in development:

cd my-medusa-storefront
mv .env.template .env

note: running the storefront in a Windows environment throws some errors, you should instead run it on a Linux-like environment.

Installing Packages

Open up your terminal and run the following command to install the react-paystack package:

yarn add react-paystack
//you might need to delete the package-lock.json

This is a React library for implementing a Paystack payment gateway in React applications. It provides us with some vital components and functions to initiate and authorize payments.

Open up your project within your code editor and navigate to the .env file to add your paystack public test key using the format below:

# Paystack Public Key
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY=yourpublicapikey

Configuring the Paystack Button Component

Next, we will need to add Paystack as a payment provider in the payment button component. Navigate to the /src/modules/checkout/components/payment-button/index.tsx file and update it using the following steps:

First, we import the PaystackButton component from the react-paystack component we installed earlier.

import { PaystackButton } from "react-paystack"

Next, we will add paystack as a case in the switch statement within the PaymentButton function. In this case, we'll return a PayStackPaymentButton component which we'll create in the next step. Update the switch statement to look like this:

switch (paymentSession?.provider_id) {
    case "stripe":
      return (
        <StripePaymentButton session={paymentSession} notReady={notReady} />
      )
    case "paystack":
      return (
        <PayStackPaymentButton session={paymentSession} notReady={notReady} />
      )
    case "manual":
      return <ManualTestPaymentButton notReady={notReady} />
    case "paypal":
      return (
        <PayPalPaymentButton notReady={notReady} session={paymentSession} />
      )
    default:
      return <Button disabled>Select a payment method</Button>
  }

Now, it's time to create the PayStackPaymentButton we used earlier. To do that, create a new component just under the PaymentButton like the one below.

/* 
Assigns the value of the environment variable 
NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY to PAYSTACK_PUBLIC_KEY 
or an empty string if the environment variable is not set
*/
const PAYSTACK_PUBLIC_KEY = process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY || ""
const PayStackPaymentButton = ({
  session,
  notReady,
}: {
  session: PaymentSession
  notReady: boolean
}) => {
  const { cart } = useCart()
  const { onPaymentCompleted } = useCheckout()

  const txRef = String(session.data?.paystackTxRef)
  const total = cart?.total || 0
  const email = cart?.email || ""
  const currency =
    cart?.region.currency_code.toUpperCase() === "NGN" ? "NGN" : "USD" || "NGN"

  return (
    <PaystackButton
      email={email}
      amount={total}
      reference={txRef}
      publicKey={PAYSTACK_PUBLIC_KEY}
      currency={currency}
      text="Pay with Paystack"
      onSuccess={onPaymentCompleted}
    />
  )
}

The code above defines a component called PayStackPaymentButton which takes two props: session of type PaymentSession and notReady of type boolean.

Within the component, it uses hooks to access the cart and checkout functionalities. It extracts the paystackTxRef from the session data, the total from the cart, and the email from the cart.

Based on the region currency code in the cart, it determines the currency to be either "NGN" or "USD" (defaulting to "NGN" if neither).

Finally, it renders the PaystackButton component imported from the react-paystack library called with various props including the extracted email, total, txRef, PAYSTACK_PUBLIC_KEY, currency, and a success callback function called onPaymentCompleted. which was destructured from the useCheckout hook.

After adding all these changes, your payment button component should look like this

import { useCheckout } from "@lib/context/checkout-context"
import { PaymentSession } from "@medusajs/medusa"
import Button from "@modules/common/components/button"
import Spinner from "@modules/common/icons/spinner"
import { OnApproveActions, OnApproveData } from "@paypal/paypal-js"
import { PayPalButtons, PayPalScriptProvider } from "@paypal/react-paypal-js"
import { useElements, useStripe } from "@stripe/react-stripe-js"
import { useCart } from "medusa-react"
import React, { useEffect, useState } from "react"
import { PaystackButton } from "react-paystack"

type PaymentButtonProps = {
  paymentSession?: PaymentSession | null
}

const PaymentButton: React.FC<PaymentButtonProps> = ({ paymentSession }) => {
  const [notReady, setNotReady] = useState(true)
  const { cart } = useCart()

  useEffect(() => {
    setNotReady(true)

    if (!cart) {
      return
    }

    if (!cart.shipping_address) {
      return
    }

    if (!cart.billing_address) {
      return
    }

    if (!cart.email) {
      return
    }

    if (cart.shipping_methods.length < 1) {
      return
    }

    setNotReady(false)
  }, [cart])

  switch (paymentSession?.provider_id) {
    case "stripe":
      return (
        <StripePaymentButton session={paymentSession} notReady={notReady} />
      )
    case "paystack":
      return (
        <PayStackPaymentButton session={paymentSession} notReady={notReady} />
      )
    case "manual":
      return <ManualTestPaymentButton notReady={notReady} />
    case "paypal":
      return (
        <PayPalPaymentButton notReady={notReady} session={paymentSession} />
      )
    default:
      return <Button disabled>Select a payment method</Button>
  }
}

const PAYSTACK_PUBLIC_KEY = process.env.NEXT_PUBLIC_PAYSTACK_PUBLIC_KEY || ""
const PayStackPaymentButton = ({
  session,
  notReady,
}: {
  session: PaymentSession
  notReady: boolean
}) => {
  const { cart } = useCart()
  const { onPaymentCompleted } = useCheckout()

  const txRef = String(session.data?.paystackTxRef)
  const total = cart?.total || 0
  const email = cart?.email || ""
  const currency =
    cart?.region.currency_code.toUpperCase() === "NGN" ? "NGN" : "NGN" || "NGN"

  return (
    <PaystackButton
      email={email}
      amount={total}
      reference={txRef}
      publicKey={PAYSTACK_PUBLIC_KEY}
      currency={currency}
      text="Pay with Paystack"
      onSuccess={onPaymentCompleted}
    />
  )
}

const StripePaymentButton = ({
  session,
  notReady,
}: {
  session: PaymentSession
  notReady: boolean
}) => {
  const [disabled, setDisabled] = useState(false)
  const [submitting, setSubmitting] = useState(false)
  const [errorMessage, setErrorMessage] = useState<string | undefined>(
    undefined
  )

  const { cart } = useCart()
  const { onPaymentCompleted } = useCheckout()

  const stripe = useStripe()
  const elements = useElements()
  const card = elements?.getElement("cardNumber")

  useEffect(() => {
    if (!stripe || !elements) {
      setDisabled(true)
    } else {
      setDisabled(false)
    }
  }, [stripe, elements])

  const handlePayment = async () => {
    setSubmitting(true)

    if (!stripe || !elements || !card || !cart) {
      setSubmitting(false)
      return
    }

    await stripe
      .confirmCardPayment(session.data.client_secret as string, {
        payment_method: {
          card: card,
          billing_details: {
            name:
              cart.billing_address.first_name +
              " " +
              cart.billing_address.last_name,
            address: {
              city: cart.billing_address.city ?? undefined,
              country: cart.billing_address.country_code ?? undefined,
              line1: cart.billing_address.address_1 ?? undefined,
              line2: cart.billing_address.address_2 ?? undefined,
              postal_code: cart.billing_address.postal_code ?? undefined,
              state: cart.billing_address.province ?? undefined,
            },
            email: cart.email,
            phone: cart.billing_address.phone ?? undefined,
          },
        },
      })
      .then(({ error, paymentIntent }) => {
        if (error) {
          const pi = error.payment_intent

          if (
            (pi && pi.status === "requires_capture") ||
            (pi && pi.status === "succeeded")
          ) {
            onPaymentCompleted()
          }

          setErrorMessage(error.message)
          return
        }

        if (
          (paymentIntent && paymentIntent.status === "requires_capture") ||
          paymentIntent.status === "succeeded"
        ) {
          return onPaymentCompleted()
        }

        return
      })
      .finally(() => {
        setSubmitting(false)
      })
  }

  return (
    <>
      <Button
        disabled={submitting || disabled || notReady}
        onClick={handlePayment}
      >
        {submitting ? <Spinner /> : "Checkout"}
      </Button>
      {errorMessage && (
        <div className="text-red-500 text-small-regular mt-2">
          {errorMessage}
        </div>
      )}
    </>
  )
}

const PAYPAL_CLIENT_ID = process.env.NEXT_PUBLIC_PAYPAL_CLIENT_ID || ""

const PayPalPaymentButton = ({
  session,
  notReady,
}: {
  session: PaymentSession
  notReady: boolean
}) => {
  const [submitting, setSubmitting] = useState(false)
  const [errorMessage, setErrorMessage] = useState<string | undefined>(
    undefined
  )

  const { cart } = useCart()
  const { onPaymentCompleted } = useCheckout()

  const handlePayment = async (
    _data: OnApproveData,
    actions: OnApproveActions
  ) => {
    actions?.order
      ?.authorize()
      .then((authorization) => {
        if (authorization.status !== "COMPLETED") {
          setErrorMessage(`An error occurred, status: ${authorization.status}`)
          return
        }
        onPaymentCompleted()
      })
      .catch(() => {
        setErrorMessage(`An unknown error occurred, please try again.`)
      })
      .finally(() => {
        setSubmitting(false)
      })
  }
  return (
    <PayPalScriptProvider
      options={{
        "client-id": PAYPAL_CLIENT_ID,
        currency: cart?.region.currency_code.toUpperCase(),
        intent: "authorize",
      }}
    >
      {errorMessage && (
        <span className="text-rose-500 mt-4">{errorMessage}</span>
      )}
      <PayPalButtons
        style={{ layout: "horizontal" }}
        createOrder={async () => session.data.id as string}
        onApprove={handlePayment}
        disabled={notReady || submitting}
      />
    </PayPalScriptProvider>
  )
}

const ManualTestPaymentButton = ({ notReady }: { notReady: boolean }) => {
  const [submitting, setSubmitting] = useState(false)

  const { onPaymentCompleted } = useCheckout()

  const handlePayment = () => {
    setSubmitting(true)

    onPaymentCompleted()

    setSubmitting(false)
  }

  return (
    <Button disabled={submitting || notReady} onClick={handlePayment}>
      {submitting ? <Spinner /> : "Checkout"}
    </Button>
  )
}

export default PaymentButton

Finally, we need to add Paystack as an option in our payment element. Navigate to /src/modules/checkout/components/payment-element/index.tsx and update the PaymentInfoMap object to look like this

const PaymentInfoMap: Record<string, { title: string; description: string }> = {
  stripe: {
    title: "Credit card",
    description: "Secure payment with credit card",
  },
  "stripe-ideal": {
    title: "iDEAL",
    description: "Secure payment with iDEAL",
  },
  paypal: {
    title: "PayPal",
    description: "Secure payment with PayPal",
  },
  paystack: {
    title: "Paystack",
    description: "Secure payment with Paystack",
  },
  manual: {
    title: "Test payment",
    description: "Test payment using medusa-payment-manual",
  },
}

This code defines a constant variable named "PaymentInfoMap" as an object with key-value pairs. The keys are strings representing various payment methods, and the values are objects with two properties: "title" and "description".

And voila, you've successfully integrated the Paystack payment processor into the Medusa e-commerce application. Here's a quick demo of the checkout process

Conclusion

In this article, we've covered how to implement a custom payment processor like Paystack into a Medusa e-commerce application.

The process involves setting up the Medusa backend and admin dashboard, installing the necessary packages, configuring the backend to include the Paystack plugin, and adding Paystack as a payment option in the admin dashboard. Additionally, the storefront needs to be set up and configured to integrate Paystack as a payment provider. This includes installing the Medusa Next.js starter template, configuring the Paystack button component in the payment button module, and adding paystack as an option in the payment element.

By following these steps, you can successfully integrate Paystack into MedusaJS and offer seamless payment experiences to customers. The integration of Paystack expands the payment options available to customers, enhances security, and ensures a smooth checkout process, ultimately improving the overall user experience and driving customer satisfaction.

Resources

  1. Medusa Documentation: The official documentation of Medusa provides in-depth information about the platform's features, architecture, and usage. It serves as a comprehensive guide to help you understand and utilize Medusa effectively.

  2. Medusa GitHub Repository: The GitHub repository of Medusa houses the source code, issue tracker, and community contributions. You can explore the repository to access the latest updates, contribute to the project, or report any issues you encounter.

  3. Paystack Documentation: If you want to delve deeper into Paystack's capabilities and explore its features, the Paystack documentation provides comprehensive resources, including API references, integration guides, and troubleshooting tips.

  4. Article Code Repository: Access the GitHub repository containing the code for this article to explore the complete implementation of the Paystack custom payment gateway in Medusa. The repository includes the backend and frontend code, as well as instructions for setup and configuration.

20
Subscribe to my newsletter

Read articles from Tonie directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Tonie
Tonie

Craftsman