Skip to content

Payment Gateways ​

We use the following off-site gateways. The following gateways can be enabled and configured in the control panel and after that you can use the following tag (inside your checkout tag) to loop over your enabled gateways.

gateway_list tag ​

Below an example to generate a list of gateway providers in a select list

{exp:reinos_store:checkout}
    {!-- This is a select list, but your can convert it to anything you like. As long we have an input[gateway] field in the checkout tag  --}
    <select name="gateway">
        <option value="">Choose your gateway</option>
        {exp:reinos_store:gateway_list}
            <option id="{gateway:id}" value="{gateway:name}">{gateway:label}</option>
        {/exp:reinos_store:gateway_list}
    </select>
{/exp:reinos_store:checkout}

However, it is also possible to just enable one gateway by providing an hidden input

<input name="gateway" type="hidden" value="manual">

Template parameter ​

Alternative it is also possible to enable just one payment gateway via the template parameter payment_gateway in your {exp:reinos_store:checkout} tag.

{exp:reinos_store:checkout payment_method="manual"}

Manual ​

By using this Gateway you can order any product without doing a real payment. Mostly this is used to send the customer a custom invoice. But it can also be used when developing your site where you cannot use the real provider.

payment_gateway="manual"

Mollie ​

This is a Dutch payment provider that support a large amount of payment methods.

Usage ​

Go to your dashboard --> Developers --> API Keys. There you see an API key for either testing or for production. Both API keys are needed, so you can copy both.

payment_gateway="mollie"

Multisafepay ​

This is another Dutch payment provider that support a large amount of payment methods.

Usage ​

Go to your dashboard --> Settings --> Website --> API Keys. By default you have a production account, but you can also create a developer account via https://testmerchant.multisafepay.com to obtain a test API key

payment_gateway="multiSafepay"

Paypal ​

For Paypal we use the REST client to give the best Paypal experience.

Usage ​

  • Go to developer.paypal.com and login with your own credentials.
  • Navigate to "My Apps and Credentials". By default, there is an app created for the sandbox (test) version but for the Live version you have to create a new app.
  • When your app has been created, you can copy the Client ID and the Secret to the Store Paypal fields.
payment_gateway="paypal"

Stripe (Checkout) ​

We support Stripe checkout. See the official documentation https://stripe.com/docs/payments/checkout

payment_gateway="stripe"

Usage ​

  • Go to developer --> API Keys
  • Copy the Publishable and the Secret key and paste them in the payment overview for Stripe

Stripe (on-site, card) ​

With this gateway the customer enters their card details on your own checkout page, using the Stripe Payment Element, instead of being redirected to a page hosted by Stripe. Card details never touch your server (PCI SAQ A): Stripe.js handles the card field.

This gateway supports card payments only. For iDEAL, Bancontact and other payment methods, use the Stripe (Checkout) gateway or another gateway.

Available from v3.12.0

payment_gateway="stripe_onsite"

Usage ​

Configure Stripe (on-site, card) in the payment gateway settings in the CP:

FieldDescription
Debug modeOn uses the test fields, off uses the live fields
Test/Live Secret KeyStripe Dashboard → Developers → API keys
Test/Live Publishable KeyStripe Dashboard → Developers → API keys
Test/Live Webhook Signing SecretSee Webhook below

Template changes ​

The default templates (checkout.html, checkout-single-page.html and ajax_checkout_gateway_list_single_page.html) already support this gateway. For your own checkout template you need two things.

A mount point for the card form, on the payment step, inside your {exp:reinos_store:checkout} tag:

{exp:reinos_store:gateway_list}
    <input id="{gateway:id}" type="radio" name="gateway" value="{gateway:name}" />
    <label for="{gateway:id}">{gateway:label}</label>

    {if gateway:name == 'stripe_onsite'}
        <div id="reinos-stripe-elements-errors"></div>
        <div id="reinos-stripe-elements" style="display:none;"></div>
    {/if}
{/exp:reinos_store:gateway_list}

And, once on the same page, the tag that loads Stripe.js and the script of the module:

{exp:reinos_store:stripe_onsite_js}

The script does the rest: it shows the card form when the customer selects this gateway, authorizes the card when the checkout form is submitted, and then posts the form as usual. Your own submit logic does not need to change.

If your template replaces the gateway list with AJAX (like checkout-single-page.html), place the mount point outside the part that is replaced, otherwise the card form is reset on every refresh. After each refresh, call:

window.reinosStoreStripeOnsite.sync();

This keeps the card form and updates the amount when the cart total has changed.

Webhook ​

The CP shows the notify URL for this gateway. In the Stripe Dashboard, go to Developers → Webhooks and add an endpoint (a "destination") with that URL. Subscribe it to at least payment_intent.succeeded and payment_intent.payment_failed. Then copy the Signing secret (whsec_...) of that endpoint into the matching Webhook Signing Secret field in the CP.

Without a signing secret, webhook calls are still processed, but without signature verification, and an error is logged. Always set it in production.

To test webhooks locally, use the Stripe CLI and put the whsec_... it prints in the test field:

stripe listen --forward-to "<your-notify-url>"

How the payment works ​

  1. When the customer selects the gateway, a Stripe PaymentIntent is created for the current cart total and the card form is shown.
  2. On checkout, the card is authorized, not charged. 3D Secure is handled on the page by Stripe.
  3. The checkout form is then validated on the server. If validation fails, the customer returns to the form and nothing is charged. The authorization is released when the page requests a new PaymentIntent.
  4. After the order is created, the module checks the PaymentIntent (it must belong to this cart, not be used for another order, and match the order amount and currency) and only then captures the payment. The order is marked as paid.
  5. If a check fails, the order is set to failed, the inventory is restored and the authorization is cancelled.

Refunds can be issued from the order in the CP.

Testing ​

Use Stripe's test cards in test mode, for example:

  • 4242 4242 4242 4242: succeeds without 3D Secure
  • 4000 0025 0000 3155: requires 3D Secure
  • 4000 0000 0000 9995: is declined

Use any future expiry date and any 3-digit CVC.

If Link is enabled in your Stripe account, the card form also offers the customer to save their details with Link. The name, email and phone number from the checkout are prefilled. A customer who is logged in to Link must first click Confirm in the Link box to select a saved card, before clicking your checkout button. You can turn Link off in the Stripe Dashboard under Settings → Payment methods → Link.

More gateways ​

Available from v3.13.0

Besides the gateways above, the module ships with the following gateways. Each one sends the customer to a payment page hosted by the provider (no card data on your site). Click a gateway for its setup.

Gatewaypayment_gatewayRegionRefunds from the CP
SquaresquareUSA, Canada, UK, EU, Australia, JapanYes
Authorize.netauthorize_netUSA, Canada, UK, EU, AustraliaYes
NMInmiUSA, CanadaYes
ClovercloverUSA, CanadaYes
Elavon ConvergeconvergeUSA, CanadaYes
Mercado Pagomercado_pagoLatin AmericaYes
Pay.pay_nlNetherlands, BelgiumYes
BuckaroobuckarooNetherlands, BelgiumYes
AdyenadyenWorldwideYes
Viva.comviva_walletEuropeYes
Worldline SaferpaysaferpaySwitzerland, EuropeYes
WorldpayworldpayUK, worldwideYes
OpayoopayoUK, IrelandNo
GoCardlessgo_cardlessUK, EurozoneYes
PayMongopaymongoPhilippinesYes
XenditxenditPhilippinesYes
MidtransmidtransIndonesiaYes
RazorpayrazorpayIndiaYes
PaystackpaystackNigeria, Ghana, South Africa, KenyaYes
FlutterwaveflutterwaveAfricaYes
Lemon Squeezylemon_squeezyWorldwide (merchant of record)Yes

How these gateways work ​

  1. On checkout, the module creates the payment at the provider and sends the customer to the provider's payment page.
  2. The customer returns to your site and/or the provider calls the notify URL (webhook). The CP shows this URL for gateways where you have to enter it in the provider's dashboard.
  3. In both cases the module fetches the payment from the provider's API and checks that it belongs to the order, with the right amount and currency, before it changes the order status. The content of a webhook is never trusted on its own.

Some rules that apply to all of them:

  • Debug mode switches between the test and live fields of the gateway. Every gateway has its own test or sandbox account at the provider.
  • Webhook secrets: set them. Without one, most gateways still process the webhook (the status is fetched from the API anyway) and log an error. For Clover and Lemon Squeezy the secret is required, because there the webhook links the payment to the order.
  • A paid order is never set back to an unpaid status, for example by a late webhook of an earlier, abandoned attempt. Refunds and chargebacks are still applied.
  • Currencies: some gateways only support certain currencies. The checkout shows an error when the store currency is not supported.
  • Refunds are always for the full order amount. The refund button in the CP is shown for every gateway that supports refunds via its API.

Before you go live ​

These gateways were built against the providers' documentation and tested automatically, but not yet against every provider's live environment. Test each gateway you use in its test mode first:

  1. A successful payment: the order becomes complete, the customer lands on the order page and receives the confirmation email.
  2. A cancelled payment: the customer returns to the checkout and the order becomes cancelled.
  3. A failed payment followed by a successful retry, if the provider's payment page allows retrying.
  4. The webhook: check the Store log in the CP for [NotifyEvent] lines and make sure there is no webhook verification failed error.
  5. A refund from the CP, if you use refunds.

Each gateway section also has a test checklist with the points specific to that provider.

Not listed ​

If you'd like to use a different Gateway, please let me know, so I can add this for you (not free).

Custom Gateways ​

Reinos Store Module Gateways are vital for your store, as they facilitate the actual payment process between your customers and you. We currently support a few native gateways. However, since there are many payment providers worldwide, we cannot support them all out of the box.

To address this, we have implemented a clean gateway system built on top of the Omnipay library, making it easy for you to add your own payment provider.

TL;DR ​

Below we will explain a bit how the file should look like, but if you don't have time to read you can just look at the file example_gateway/system/user/addons/reinos_store_gateway_test/Gateway.php that is shipped with the Reinos Store module

Folder structure ​

The folder and file structure is essential for the Reinos Store module to recognize and support your new gateway.

To add a new gateway, you need to use the following folder structure:

└── system
│   └── user
│   │   └── addons
│   │   │   └── reinos_store_gateway_<your_gateway_name>
|   │   │   │   └── Gateway.php

So if your gatway is called instapay you structure looks like

└── system
│   └── user
│   │   └── addons
│   │   │   └── reinos_store_gateway_instapay
|   │   │   │   └── Gateway.php

Gateway.php ​

The Gateway.php file is the file that connect your payment to the Reinos Store module. It should contain a couple of functions that will be called by the module.

$info varibale ​

In the $info variable we define the names and fields of this Gateway.

name ​

This is the short_name of the gateway. It should be lowercase snake_case.

public static $info = [
    'name' => 'instapay',
];
label ​

The label is displayed both in the Control Panel (CP) and in the payment selector. It represents the clean, user-friendly name of the gateway.

public static $info = [
    'label' => 'InstaPay',
];
desc ​

The description (desc) provides details about the payment gateway, offering a clean and concise explanation. It may also include information on how to obtain the required keys.

public static $info = [
    'desc' => 'You can get your KEYS from the InstaPay dashboard (Developer --> Api Keys)',
];
showNotifyUrl ​

For some gateways, the notify URL needs to be visible to the public so it can be copied into the payment provider's dashboard. This flag enables that functionality. When set to true, the notify URL will be displayed publicly in the Control Panel (CP).

public static $info = [
    'showNotifyUrl' => true,
];
offsite ​

Some payment providers offer an offsite payment selection, while others handle payments directly during checkout. Here, you can define whether this gateway is an offsite or onsite gateway.

public static $info = [
    'offsite' => true,
];
fields ​

Most gateways require an API key or similar credentials. With the fields section, you can define these or other necessary fields, allowing you to fill them directly within your Control Panel (CP) when enabling this gateway.

public static $info = [
     'fields' => [
        'debug' => [
            'type' => 'yes_no',
            'label' => 'Debug mode',
            'desc' => 'Enable debug mode',
        ],
        'test_api_key' => [
            'type' => 'text',
            'label' => 'Test API KEY',
        ],
        'live_api_key' => [
            'type' => 'text',
            'label' => 'Live API KEY',
        ],
    ]
];

constructor() ​

In the constructor, we create the actual payment instance using the Omnipay library. This is also where we check if an API key is set.

Depending on your gateway, there may be additional steps required, but that is up to you to implement.

Below, you'll find the bare minimum example.

public function __construct($gatewayData)
{
    // set the gateway data
    // this hold all data that was set in CP for the dynamic fields 
    $this->gatewayData = $gatewayData;

    // init the gateway from the omnipay library
    $this->gateway = \Omnipay\Omnipay::create('YourGatewayName');

    // set the api key based on the debug setting
    // this is up to you how you want to handle this
    $apiKey = $this->gatewayData['debug'] === 'y' ? $this->gatewayData['test_api_key'] : $this->gatewayData['live_api_key'];

    // pass the api key to the gateway
    $this->gateway->setApiKey($apiKey);
}

create() ​

The create() function is responsible for initiating the payment. It uses the Omnipay library's purchase() function to call the actual payment provider.

Depending on your gateway, you can extend the purchase data as needed.

 public function create($order, $currency, $gatewayData = [])
    {
        // create a purchase request
        $response = $this->gateway->purchase(
            [
                'currency' => $currency,
                'amount' => $order->total_plus_tax,
                'description' => 'Order #' . $order->order_id,
                // generate the return and notify url
                // you can use the helper functions here
                'returnUrl' => $this->generateReturnUrl($order),
                'notifyUrl' => $this->generateNotifyUrl(self::$info['name']),
                // generate the metadata, such as the order data
                'metadata' => $this->generateMetaData($order),
            ]
        )->send();

        // format the response so the Store module can handle it
        return $this->formatResponse($response, $this);
    }

saveStatus ​

The saveStatus() call is needed to define which status should be set. By default, the Omnipay library provides several methods to determine the status. You can refer to the documentation here: Omnipay Response Statuses.

Depending on your gateway implementation, you can choose to use these methods or implement your own. Below, you'll find a complete example.

protected function saveStatus($order, $transaction)
{
    // this one is a bit up to you
    // not every payment provider has the same status or has implemented the same status methods
    // below an example of how you can handle this
    if ($order && $transaction) {
        $isPaid = $transaction->isPaid();

        if ($transaction->isPaid() && !$transaction->isRefunded() && !$transaction->hasChargebacks()) {
            $newStatus = REINOS_STORE_STATUS_PAID;
        } elseif ($transaction->isOpen()) {
            $newStatus = REINOS_STORE_STATUS_OPEN;
        } elseif ($transaction->isPending()) {
            $newStatus = REINOS_STORE_STATUS_PENDING;
        } elseif ($transaction->isExpired()) {
            $newStatus = REINOS_STORE_STATUS_EXPIRED;
        } elseif ($transaction->isCancelled()) {
            $newStatus = REINOS_STORE_STATUS_CANCELLED;
        } elseif ($transaction->hasRefunds()) {
            $newStatus = REINOS_STORE_STATUS_REFUNDED;
        } elseif ($transaction->hasChargebacks()) {
            $newStatus = REINOS_STORE_STATUS_PAID_CHARGEBACK;
        } else {
            $newStatus = REINOS_STORE_STATUS_FAILED;
        }

        // this is the most important part
        // this will update the order, inventory and sync the entry if needed
        return $this->updateVitals($order, $newStatus, $isPaid);
    }

    return false;
}

completeOrder ​

completeOrder() is called when we can complete an order. Here we fetch the transaction and call in the saveStatus()

public function completeOrder($order)
{
    // complete an order by fetching the transaction
    // and update the status based on the transaction
    $transactionResponse = $this->gateway->fetchTransaction(
        array('transactionReference' => $order->transaction_id)
    )->send();

    return $this->saveStatus($order, $transactionResponse);
}
getTransactionIdOnNotifyCall ​

When a payment provider calls the notify URL to inform you about a status update, it will usually send the transaction ID along with the request.

The getTransactionIdOnNotifyCall() function is called to fetch that ID. It's up to you and your payment provider to determine how and where to retrieve it. However, in most cases, it is sent as a POST parameter, often labeled as id.

public function getTransactionIdOnNotifyCall()
{
    return ee()->input->get_post('id');
}

HostedGateway ​

Available from v3.13.0

If your payment provider has a REST API with a hosted payment page, you don't need Omnipay. Extend HostedGateway instead of Gateway. All gateways listed above are built this way, so they are good examples (system/user/addons/reinos_store/Core/Gateway/Paymongo.php is a short one).

HostedGateway gives you:

  • jsonRequest() / httpRequest(): API calls with curl, without extra dependencies
  • modeSetting('secret_key'): reads test_secret_key or live_secret_key, depending on the debug setting
  • orderAmount() / orderAmountInMinorUnits(): the order total as "49.95" or 4995 (zero- and three-decimal currencies included)
  • redirectResponse() / formPostResponse(): send the customer to the payment page with a redirect or an auto-submitted form
  • verifyWebhook(), verifyHmac(), requestHeader(), rawRequestBody(): helpers to verify webhooks
  • saveStatus(): you only implement resolveStatus(), which maps the provider's payment to a store status. A paid order is never set back to an unpaid status.

A minimal gateway looks like this:

php
namespace Reinos\Store\Core\Gateway;

class Instapay extends HostedGateway
{
    public static $info = [
        'name' => 'instapay',
        'label' => 'InstaPay',
        'desc' => 'Get your keys from the InstaPay dashboard (Developers -> API keys)',
        'showNotifyUrl' => true,
        'offsite' => true,
        'currencies' => ['PHP'],
        'fields' => [
            'debug' => ['type' => 'yes_no', 'label' => 'Debug mode'],
            'test_secret_key' => ['type' => 'text', 'label' => 'Test Secret Key'],
            'live_secret_key' => ['type' => 'text', 'label' => 'Live Secret Key'],
            'test_webhook_secret' => ['type' => 'text', 'label' => 'Test Webhook Secret'],
            'live_webhook_secret' => ['type' => 'text', 'label' => 'Live Webhook Secret'],
        ],
    ];

    public function create($order, $currency, $gatewayData = [])
    {
        if ($error = $this->unsupportedCurrency($currency)) {
            return $this->errorResponse($error);
        }

        $result = $this->api('POST', '/payments', [
            'amount' => $this->orderAmountInMinorUnits($order, $currency),
            'currency' => $currency,
            'reference' => 'order-' . $order->order_id . '-' . $order->hash,
            'return_url' => $this->generateReturnUrl($order),
            'cancel_url' => $this->generateReturnUrl($order, '&cancel=true'),
        ]);

        if (isset($result['error'])) {
            return $result;
        }

        // the id is saved as the order's transaction_id
        return $this->redirectResponse($result['id'] ?? null, $result['payment_url'] ?? null);
    }

    public function completeOrder($order)
    {
        // always fetch the payment from the API, never trust the return URL or webhook payload
        $payment = $this->api('GET', '/payments/' . rawurlencode($order->transaction_id));

        if (isset($payment['error'])) {
            return false;
        }

        return $this->saveStatus($order, $payment);
    }

    protected function resolveStatus($payment)
    {
        switch ($payment['status'] ?? '') {
            case 'paid':
                return REINOS_STORE_STATUS_COMPLETE;
            case 'expired':
                return REINOS_STORE_STATUS_EXPIRED;
            default:
                return null; // not paid yet, leave the order as it is
        }
    }

    public function getTransactionIdOnNotifyCall()
    {
        $verified = $this->verifyWebhook($this->modeSetting('webhook_secret'), function ($secret) {
            return $this->verifyHmac($this->rawRequestBody(), $this->requestHeader('X-Signature'), $secret);
        });

        return $verified ? ($this->jsonRequestBody()['payment_id'] ?? '') : '';
    }

    private function api($method, $path, $body = null)
    {
        return $this->jsonRequest($method, 'https://api.instapay.example/v1' . $path, $body, [
            'bearer' => $this->modeSetting('secret_key'),
        ], function ($json) {
            return $json['error']['message'] ?? null;
        });
    }
}

Implement refund($order) as well when the provider supports refunds via its API; the refund button in the CP then appears automatically.

Before you go live, check that completeOrder() verifies that the payment belongs to the order and has the right amount and currency, especially when the provider's payment page lets the customer change the amount (tips, partial payments).